<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>The Long Answer</title><link>https://7strikes.dev/</link><atom:link href="https://7strikes.dev/feed.xml" rel="self" type="application/rss+xml"/><description>Notes on hard Apple platform problems.</description><language>en</language><lastBuildDate>Mon, 02 Nov 2026 19:08:24 +0000</lastBuildDate><item><title>Trust Insights, or how your app can tell when someone is being talked through a scam</title><link>https://7strikes.dev/articles/trust-insights/</link><guid isPermaLink="true">https://7strikes.dev/articles/trust-insights/</guid><pubDate>Mon, 02 Nov 2026 00:00:00 +0000</pubDate><category>App integrity and fraud assessment</category><description>iOS 27 can tell a bank app when it looks like the person confirming a transfer is being coached by someone else. What it tells you, the app code step by step, and what nobody can test yet.</description><content:encoded>&lt;p&gt;The scam that gets past every check a bank app has is the one where the victim does the work. The right person, on their own phone, signed in with Face ID, sends the money themselves, because someone on the phone has told them it is the only way to keep it safe. From the app's side, nothing is wrong. The password is right, the device is known, the person is who they say they are.&lt;/p&gt;
&lt;p&gt;These scams are not getting rarer. They are aimed at older and less technical people, and AI has made them cheaper to run: a scammer can now work in a language they do not speak, sound fluent in it, and copy the voice of the victim's grandchild.&lt;/p&gt;
&lt;p&gt;iOS 27 gives apps a way to ask about this. With &lt;a href="https://developer.apple.com/documentation/trustinsights"&gt;Trust Insights&lt;/a&gt;, your app asks the system, before the money moves, whether it looks like someone is coaching the person through what they are doing. It gets back a risk level, and decides what to do with it.&lt;/p&gt;
&lt;p&gt;It only helps if the apps use it. A scammer who meets a warning in one bank's app will move the victim to the bank whose app does not ask. As I write this, not one of the bank or brokerage apps on my phone uses it. So here is how, with a &lt;a href="https://github.com/atekant/TrustInsightsSample"&gt;sample project&lt;/a&gt; that does all of it in one file.&lt;/p&gt;
&lt;h2 id="what-it-tells-you"&gt;What it tells you&lt;/h2&gt;
&lt;p&gt;You ask for an insight, and the one there is today is &lt;a href="https://developer.apple.com/documentation/trustinsights/islikelybeingcoachedinsight"&gt;&lt;code&gt;IsLikelyBeingCoachedInsight&lt;/code&gt;&lt;/a&gt;. Its value is &lt;code&gt;unknown&lt;/code&gt;, &lt;code&gt;medium&lt;/code&gt; or &lt;code&gt;high&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;unknown&lt;/code&gt; means the system found no sign of coaching. That is not the same as finding the transfer safe; a scam the model did not catch also comes back &lt;code&gt;unknown&lt;/code&gt;. &lt;code&gt;medium&lt;/code&gt; and &lt;code&gt;high&lt;/code&gt; are reasons to slow down, and Apple's documentation adds that coaching is not always malicious. A daughter walking her father through his first transfer is coaching too.&lt;/p&gt;
&lt;p&gt;Apple's WWDC26 session, &lt;a href="https://developer.apple.com/videos/play/wwdc2026/379/"&gt;Meet Trust Insights&lt;/a&gt;, says what goes into it. The signals taken from the device are not shared with Apple or with third parties, and the content of Photos, Messages and Mail is never used. The result can also take in Apple Account signals and velocity checks. What your app receives is the risk level, not the reasons for it.&lt;/p&gt;
&lt;p&gt;The person stays in charge. They give your app permission, and they can take it away in Settings. The session says a cooldown may apply after they turn it off, to protect people who were coached into turning it off.&lt;/p&gt;
&lt;h2 id="not-only-banks"&gt;Not only banks&lt;/h2&gt;
&lt;p&gt;The session names the scams Apple had in mind. Tech support scams, where a fake alert leads to remote access and the victim hands over control. Authority impersonation: callers posing as the bank, a government agency or the police. Family emergencies: an urgent plea for money from a relative, more and more often in a voice made with AI. In all three, someone talks the victim through the steps by phone, chat or messages.&lt;/p&gt;
&lt;p&gt;None of that is specific to banks. The operation categories cover any exchange of money, assets or content, in-game purchases included; changes to account and security details; costly services such as AI inference; and sending messages, submitting forms or signing documents. The session lists the moments worth asking about: large payments such as peer-to-peer transfers, actions that cannot be undone such as deleting an account or exporting personal data, permissions such as remote access or a new device, and sharing credentials or personal documents. The app that sells gift cards can ask before the purchase, and the remote support app can ask before it hands over the screen.&lt;/p&gt;
&lt;p&gt;It only helps where an app asks. A scammer who edits the bank's web page on the victim's computer to show a refund that never happened is out of its reach, and so is a victim who drives to an ATM. The moment the victim sends the "overpaid" money back from a phone app, or buys gift cards in one, is a moment that app can ask about. What the system looks at, Apple describes only as interaction patterns, timing, context and basic sensor data.&lt;/p&gt;
&lt;h2 id="it-all-happens-in-the-app"&gt;It all happens in the app&lt;/h2&gt;
&lt;p&gt;Trust Insights is an iOS framework, and that puts some developers off: the same bank has an Android app, and a fraud team that wants one pipeline. None of that has to change. The session describes the integration as entirely client-side. The iOS app asks before the transfer and decides what to do with the answer. Your server, your Android app and your fraud rules carry on as they are, and if the fraud team wants the result, the app records it with the transfer like any other check it runs.&lt;/p&gt;
&lt;h2 id="the-app-step-by-step"&gt;The app, step by step&lt;/h2&gt;
&lt;p&gt;The &lt;a href="https://github.com/atekant/TrustInsightsSample"&gt;sample&lt;/a&gt; is a one-screen UIKit app for a fictional bank. Everything that touches the API is in &lt;code&gt;TrustInsightsCheck.swift&lt;/code&gt;, in five steps, in the order you meet them. The code below is taken from it.&lt;/p&gt;
&lt;h3 id="1-describe-the-operation"&gt;1. Describe the operation&lt;/h3&gt;
&lt;p&gt;An evaluation request is a context: what kind of operation this is, and which insights you want. A transfer is &lt;code&gt;.payment&lt;/code&gt;; the other categories are &lt;code&gt;.account&lt;/code&gt;, &lt;code&gt;.resourceUse&lt;/code&gt;, &lt;code&gt;.communication&lt;/code&gt; and &lt;code&gt;.other&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-swift"&gt;&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt; context() -&amp;gt; CoachingContext {
    InsightEvaluator.InsightContext(
        operationCategory: .payment,
        requestedEvaluations: IsLikelyBeingCoachedInsight.request(schema: .version1),
    )
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;InsightContext&lt;/code&gt; takes a parameter pack, which makes its type long. The sample gives the types short names, &lt;code&gt;CoachingContext&lt;/code&gt; and &lt;code&gt;CoachingEvaluation&lt;/code&gt;, so the rest of the code stays readable.&lt;/p&gt;
&lt;h3 id="2-ask-for-permission-at-a-calm-moment"&gt;2. Ask for permission at a calm moment&lt;/h3&gt;
&lt;p&gt;The app needs the person's permission, and when you ask matters more than how. Ask from a deliberate action, such as a settings row, after saying what it does. Never ask in the middle of a transfer: if a scammer is on the phone, they will tell the person to tap Not Now.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-swift"&gt;&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt; requestAuthorization(&lt;span class="k"&gt;for&lt;/span&gt; context: CoachingContext) async -&amp;gt; Bool {
    &lt;span class="k"&gt;let&lt;/span&gt; status = &lt;span class="k"&gt;try&lt;/span&gt;? &lt;span class="k"&gt;await&lt;/span&gt; InsightEvaluator().requestAuthorization(&lt;span class="k"&gt;for&lt;/span&gt;: context)
    &lt;span class="k"&gt;return&lt;/span&gt; status == .authorized
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;authorizationStatus(for:)&lt;/code&gt; has five answers. &lt;code&gt;.authorized&lt;/code&gt; means go ahead. &lt;code&gt;.notDetermined&lt;/code&gt; and &lt;code&gt;.deniedRequestable&lt;/code&gt; mean you may ask, from that calm moment. &lt;code&gt;.denied&lt;/code&gt; and &lt;code&gt;.unavailable&lt;/code&gt; mean you may not; the person can still turn it on in Settings &amp;gt; Privacy &amp;amp; Security &amp;gt; Impersonation Risk Detection.&lt;/p&gt;
&lt;h3 id="3-start-the-evaluation-early"&gt;3. Start the evaluation early&lt;/h3&gt;
&lt;p&gt;The result is the return value of &lt;code&gt;requestEvaluation(context:)&lt;/code&gt;. There is no delegate, notification or extension. The session warns that it can take a couple of seconds and needs internet access, so do not wait for the Confirm button to start it. The sample starts it as soon as the transfer screen appears, and the seconds pass while the person reads the screen:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-swift"&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt; startEvaluation() {
    &lt;span class="k"&gt;let&lt;/span&gt; context = TrustInsightsCheck.context()
    pending = Task { &lt;span class="k"&gt;await&lt;/span&gt; TrustInsightsCheck.evaluate(context) }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;evaluate&lt;/code&gt; checks the authorization status, calls &lt;code&gt;requestEvaluation&lt;/code&gt;, and turns every failure into &lt;code&gt;nil&lt;/code&gt;, which means "no evaluation, use your existing checks". A failure must never block a transfer that your app would otherwise allow. These are the errors, and what the sample does with each:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;&lt;tr&gt;&lt;th&gt;EvaluationError&lt;/th&gt;&lt;th&gt;What it means&lt;/th&gt;&lt;th&gt;What to do&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;contextInvalid&lt;/code&gt;&lt;/td&gt;&lt;td&gt;The request is malformed&lt;/td&gt;&lt;td&gt;A bug; fix it&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;denied&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Permission was withdrawn&lt;/td&gt;&lt;td&gt;Carry on without it&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;notAvailable&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Trust Insights is not available&lt;/td&gt;&lt;td&gt;Carry on without it&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;rateLimitExceeded&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Too many requests&lt;/td&gt;&lt;td&gt;Check that every evaluation is reported (step 4)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;serverError&lt;/code&gt;, &lt;code&gt;localError&lt;/code&gt;&lt;/td&gt;&lt;td&gt;A transient failure&lt;/td&gt;&lt;td&gt;Try again on the next transfer, not in a loop&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;payloadSecurityError&lt;/code&gt;&lt;/td&gt;&lt;td&gt;The payload could not be secured&lt;/td&gt;&lt;td&gt;Carry on without it, and log it&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;A production app should also stop waiting after a deadline of its own and go ahead on its usual checks. If you do that, the evaluation that turns up after the deadline still has to be reported, as &lt;code&gt;.notUsedError&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id="4-decide-and-report-what-you-decided"&gt;4. Decide, and report what you decided&lt;/h3&gt;
&lt;p&gt;Every evaluation has to be reported with &lt;code&gt;reportConsumption&lt;/code&gt;, exactly once. The session is plain about it: the call is mandatory, and an app that skips it may be rate-limited. The report says what the app did with the result, and it is how Apple learns how the insight is being used.&lt;/p&gt;
&lt;p&gt;The sample puts the report on every branch, where you can see it:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-swift"&gt;&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt; decide(&lt;span class="k"&gt;_&lt;/span&gt; evaluation: CoachingEvaluation?) -&amp;gt; Friction {
    &lt;span class="c"&gt;// No evaluation: no permission, no network, or an error. The app's own checks decide, as before.&lt;/span&gt;
    guard &lt;span class="k"&gt;let&lt;/span&gt; evaluation &lt;span class="k"&gt;else&lt;/span&gt; { &lt;span class="k"&gt;return&lt;/span&gt; .none }

    &lt;span class="k"&gt;switch&lt;/span&gt; evaluation.insight.outcome {
    &lt;span class="k"&gt;case&lt;/span&gt; .success(.unknown):
        &lt;span class="c"&gt;// No evidence of coaching. That is not evidence of safety, so friction stays as it was.&lt;/span&gt;
        evaluation.reportConsumption(.usedUnchangedFriction, insightsUsed: [evaluation.insight])
        &lt;span class="k"&gt;return&lt;/span&gt; .none
    &lt;span class="k"&gt;case&lt;/span&gt; .success(.medium):
        evaluation.reportConsumption(.usedIncreasedFriction, insightsUsed: [evaluation.insight])
        &lt;span class="k"&gt;return&lt;/span&gt; .warn
    &lt;span class="k"&gt;case&lt;/span&gt; .success(.high):
        evaluation.reportConsumption(.usedIncreasedFriction, insightsUsed: [evaluation.insight])
        &lt;span class="k"&gt;return&lt;/span&gt; .hold
    &lt;span class="k"&gt;case&lt;/span&gt; .success:
        &lt;span class="c"&gt;// A level newer than this sample. Do not guess what it means.&lt;/span&gt;
        evaluation.reportConsumption(.notUsedError)
        &lt;span class="k"&gt;return&lt;/span&gt; .none
    &lt;span class="k"&gt;case&lt;/span&gt; .failure(&lt;span class="k"&gt;let&lt;/span&gt; error):
        &lt;span class="c"&gt;// The evaluation came back, but the insight inside it failed. It still has to be reported.&lt;/span&gt;
        log.notice(&lt;span class="s"&gt;"Insight failed: \(String(describing: error), privacy: .public)"&lt;/span&gt;)
        evaluation.reportConsumption(.notUsedError)
        &lt;span class="k"&gt;return&lt;/span&gt; .none
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two branches are easy to miss. The &lt;code&gt;.failure&lt;/code&gt; case is an evaluation that came back with a failed insight inside it, and it still counts as an evaluation. And a transfer the person cancels before confirming still had an evaluation, which the sample reports as &lt;code&gt;.notUsedNotNeeded&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;What the warning says matters as much as when it appears. The sample's &lt;code&gt;medium&lt;/code&gt; warning asks whether someone is telling the person what to do, and says the bank will never ask them to move money to keep it safe. Its &lt;code&gt;high&lt;/code&gt; response pauses the transfer and promises a call back on the number the bank already has. The Swift Dev's &lt;a href="https://www.theswift.dev/posts/trust-insights-sensitive-ios-flows/"&gt;design guide&lt;/a&gt; goes much further into this than I do here, and it is worth reading before you write your own copy.&lt;/p&gt;
&lt;h3 id="5-only-add-friction-and-keep-a-record"&gt;5. Only add friction, and keep a record&lt;/h3&gt;
&lt;p&gt;A decision made in the app can be changed by anyone who can change the app. For a warning, that does not matter. A changed app that fakes &lt;code&gt;unknown&lt;/code&gt; gets the transfer the bank would have allowed before Trust Insights. So the sample only ever adds friction: &lt;code&gt;unknown&lt;/code&gt; leaves the transfer as it was, and nothing skips a check.&lt;/p&gt;
&lt;p&gt;Apple's documentation shows the other direction too: its example uses a result with no sign of coaching to skip one of the app's checks, and &lt;code&gt;.usedReducedFriction&lt;/code&gt; exists for that. That needs a result your server can trust. The evaluation carries a &lt;a href="https://developer.apple.com/documentation/trustinsights/insightevaluation/signedpayload"&gt;&lt;code&gt;signedPayload&lt;/code&gt;&lt;/a&gt; for server-side validation, but as of November 2026 what a server needs to check it is not public. The sample does not use it, and neither does this article.&lt;/p&gt;
&lt;p&gt;Last, the sample logs the result and the friction with the transfer. A real app keeps them in its own records, because the next section is about what the fraud team does with them.&lt;/p&gt;
&lt;h2 id="what-you-cannot-test-and-whose-job-that-is"&gt;What you cannot test, and whose job that is&lt;/h2&gt;
&lt;p&gt;I ran the sample on a device, and the whole flow works: permission, the evaluation, the report. What I could not do is make it see a scam. Apple does not say what it treats as coaching, so there is no way to set up the conditions for a &lt;code&gt;medium&lt;/code&gt; or a &lt;code&gt;high&lt;/code&gt; on purpose. The session says values and errors can be overridden with launch arguments in the scheme, and refers to the documentation for the list. The documentation does not have the list yet. Until it does, the branches for &lt;code&gt;medium&lt;/code&gt; and &lt;code&gt;high&lt;/code&gt; can only be tested by reading them.&lt;/p&gt;
&lt;p&gt;That leaves the judgement with the banks, which is where it belongs anyway. How much weight a &lt;code&gt;medium&lt;/code&gt; gets is a decision for your fraud team, made from your own numbers: what happened to the transfers that got each value, and how many of the &lt;code&gt;unknown&lt;/code&gt;s turned out to be fraud. Where the results do not match what your fraud team sees, tell Apple through &lt;a href="https://feedbackassistant.apple.com"&gt;Feedback Assistant&lt;/a&gt;. The model gets better when the banks using it say where it is wrong.&lt;/p&gt;
&lt;h2 id="start"&gt;Start&lt;/h2&gt;
&lt;p&gt;If you work on a bank, brokerage, payments or crypto app, put this on the screen where money leaves the account. If you work on any other app where money, an account or remote access changes hands, put it there. It is all app code, it fits in one file, and the &lt;a href="https://github.com/atekant/TrustInsightsSample"&gt;sample&lt;/a&gt; is there to copy from. Every app that adopts it leaves scammers one less place to send their victims.&lt;/p&gt;
&lt;h2 id="further-reading"&gt;Further reading&lt;/h2&gt;
&lt;p&gt;Anton Gubarenko's &lt;a href="https://antongubarenko.substack.com/p/ios-27-trust-insights-framework"&gt;iOS 27: Trust Insights Framework&lt;/a&gt; is a longer walk through the API, with more on the kinds of apps outside banking that can use it. The Swift Dev's &lt;a href="https://www.theswift.dev/posts/trust-insights-sensitive-ios-flows/"&gt;Trust Insights: Detect Coerced Actions In Sensitive iOS Flows&lt;/a&gt; is the one to read for the design of the warnings themselves.&lt;/p&gt;
&lt;h2 id="sources"&gt;Sources&lt;/h2&gt;
&lt;ul&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/trustinsights"&gt;Trust Insights&lt;/a&gt;, Apple's documentation&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/trustinsights/insightevaluation"&gt;&lt;code&gt;InsightEvaluation&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://developer.apple.com/documentation/trustinsights/insightevaluation/signedpayload"&gt;&lt;code&gt;signedPayload&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/trustinsights/islikelybeingcoachedinsight"&gt;&lt;code&gt;IsLikelyBeingCoachedInsight&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/videos/play/wwdc2026/379/"&gt;Meet Trust Insights&lt;/a&gt;, WWDC26&lt;/li&gt;&lt;li&gt;&lt;a href="https://github.com/atekant/TrustInsightsSample"&gt;TrustInsightsSample&lt;/a&gt;, the sample project&lt;/li&gt;&lt;li&gt;&lt;a href="https://antongubarenko.substack.com/p/ios-27-trust-insights-framework"&gt;iOS 27: Trust Insights Framework&lt;/a&gt;, Anton Gubarenko&lt;/li&gt;&lt;li&gt;&lt;a href="https://www.theswift.dev/posts/trust-insights-sensitive-ios-flows/"&gt;Trust Insights: Detect Coerced Actions In Sensitive iOS Flows&lt;/a&gt;, The Swift Dev&lt;/li&gt;&lt;/ul&gt;</content:encoded></item><item><title>App Attest, or how you can trust an app that cannot keep a secret</title><link>https://7strikes.dev/articles/app-attest/</link><guid isPermaLink="true">https://7strikes.dev/articles/app-attest/</guid><pubDate>Tue, 27 Oct 2026 00:00:00 +0000</pubDate><category>App integrity and fraud assessment</category><description>An API key in your app is not a secret; anyone with the binary can read it. Keep secrets on the server, and use App Attest so the server can tell your real app from everything else. The app side, the server checks, and the things that trip people up.</description><content:encoded>&lt;p&gt;If your app talks to a service with an API key that is compiled into the app, that key is not a secret. Anyone who has the app has the binary, and anyone who has the binary can read what is in it. Reading compiled code has never been easier than it is today, and the AI assistants that now help with it have made it faster still. OWASP lists keys and other sensitive data &lt;a href="https://mas.owasp.org/MASWE/MASVS-STORAGE/MASWE-0004/"&gt;hardcoded in the app package&lt;/a&gt; as a weakness in its own right, and obfuscation does not change that.&lt;/p&gt;
&lt;p&gt;So the secrets go on your server, and your server makes the calls that need them. That moves the problem to the server, which now has to decide who is asking. Your app is one caller. A script that copied your app's requests is another, and so is a modified copy of your app. Apple puts the core of the problem plainly in its &lt;a href="https://developer.apple.com/documentation/devicecheck/establishing-your-app-s-integrity"&gt;App Attest documentation&lt;/a&gt;: you can't rely on your app's logic to perform security checks on itself, because a compromised app can falsify the results.&lt;/p&gt;
&lt;p&gt;That is the job App Attest does. It lets your server check, with a key that the app itself can never read, that a request comes from your app, signed by your team, running on genuine Apple hardware. It is less work than most people expect.&lt;/p&gt;
&lt;h2 id="what-app-attest-tells-your-server"&gt;What App Attest tells your server&lt;/h2&gt;
&lt;p&gt;Apple's WWDC26 session &lt;a href="https://developer.apple.com/videos/play/wwdc2026/201/"&gt;Secure your apps with App Attest&lt;/a&gt; is the best explanation Apple has of all this, and it is twenty minutes well spent. In short, an attestation tells your server that:&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;the key belongs to an app with your Team ID and bundle identifier, so a copy re-signed by someone else shows up as someone else;&lt;/li&gt;&lt;li&gt;the key was generated in the Secure Enclave of a real Apple device;&lt;/li&gt;&lt;li&gt;how the app was launched (App Store, TestFlight, development signing and so on) and which bundle version is running &lt;em&gt;(on iOS 27 and later)&lt;/em&gt;;&lt;/li&gt;&lt;li&gt;where App Attest is now available, that the Mac had System Integrity Protection and full security mode turned on &lt;em&gt;(on macOS 27 and later)&lt;/em&gt;.&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;After that, the app signs individual requests with the same key, and your server checks each signature.&lt;/p&gt;
&lt;p&gt;There are two things it does not do. It does not identify a user; that is still the job of your sign-in process. And it cannot make a device with a modified operating system honest. Apple says so in its &lt;a href="https://developer.apple.com/documentation/devicecheck/assessing-fraud-risk"&gt;fraud assessment article&lt;/a&gt;: an attacker who modifies the operating system might get around it. That is what the fraud metric, further down, is for.&lt;/p&gt;
&lt;blockquote class="callout important"&gt;&lt;p&gt;&lt;strong class="label"&gt;Important:&lt;/strong&gt; App Attest is not jailbreak detection. A jailbroken device runs a modified operating system, and that operating system may be modified so that attestations and assertions still pass. Apple's documentation calls this a compromised device; the &lt;a href="https://developer.apple.com/videos/play/wwdc2026/201/"&gt;WWDC26 session&lt;/a&gt; says a compromised device could still pass attestations. An Apple engineer put it more directly in a &lt;a href="https://developer.apple.com/forums/thread/749242"&gt;forum reply&lt;/a&gt;: App Attest gives general guarantees that your app is running unmodified, but "in the cases of a jailbreak / root, the latter may not always hold true". Do not trust a jailbroken device, whatever its attestations say. And do not rely on jailbreak detection in the app instead: Apple DTS &lt;a href="https://developer.apple.com/forums/thread/692391"&gt;advises against it&lt;/a&gt;, because such checks depend on low-level details that break as the system changes.&lt;/p&gt;&lt;/blockquote&gt;
&lt;p&gt;Why a compromised device can't be trusted is easy to see once you look at what a check is. Every check your app makes is code running on the device: if the answer is good, carry on, otherwise refuse. Whoever controls the operating system controls that code too. Flipping one comparison so that a failing check passes, making a system call return the answer your app was hoping for, loading their own code into your app's process: on a device whose operating system is theirs, all of that is routine work. The same goes for the frameworks your app calls, the one that talks to App Attest included. The &lt;a href="https://developer.apple.com/videos/play/wwdc2026/201/"&gt;WWDC26 session&lt;/a&gt; walks through that very case on a Mac with System Integrity Protection turned off, where the attacker modifies the App Attest framework itself. On the Mac, the key's access policy still gives it away, which is why the section on the Mac below says to check it. The lesson holds everywhere: checks belong on your server, where nobody but you can edit them, and even your server can only weigh what such a device sends against every other signal it has.&lt;/p&gt;
&lt;p&gt;OWASP, which I quoted at the top, does want apps to look for jailbreaks. Its &lt;a href="https://mas.owasp.org/MASVS/11-MASVS-RESILIENCE/"&gt;MASVS-RESILIENCE&lt;/a&gt; controls and the weakness it calls &lt;a href="https://mas.owasp.org/MASWE/MASVS-RESILIENCE/MASWE-0051/"&gt;Root/Jailbreak Detection Not Implemented&lt;/a&gt; cover exactly that, and there are commercial products that do it for you. The rest of what OWASP says lands where this article does. These are defence-in-depth measures for apps with something worth protecting, and leaving them out is not a vulnerability in itself. They must never replace other security controls, none of them is fully effective, and the checks that are publicly documented are easy to get around. OWASP's own advice is to back them with device attestation verified on the server, so the server can still act when the checks in the app have been defeated. If your risk calls for them, add them, or buy them, as one more signal for your server. Keep the decision on the server, and expect them to need updating when the system changes.&lt;/p&gt;
&lt;h2 id="how-it-works"&gt;How it works&lt;/h2&gt;
&lt;p&gt;There are three steps, and only one of them talks to Apple.&lt;/p&gt;
&lt;ol&gt;&lt;li&gt;The app generates a key. The private half stays in the Secure Enclave, and the app gets back an identifier, which it keeps.&lt;/li&gt;&lt;li&gt;The app attests the key once. It asks your server for a challenge, and the system contacts Apple, which returns an attestation object. The app passes that to your server, which checks it and stores the public key.&lt;/li&gt;&lt;li&gt;From then on, for each request that matters, the app asks your server for a challenge and signs the request together with it. That is an assertion. It happens on the device and does not involve Apple at all.&lt;/li&gt;&lt;/ol&gt;
&lt;h2 id="the-app-side"&gt;The app side&lt;/h2&gt;
&lt;blockquote class="callout"&gt;&lt;p&gt;&lt;strong class="label"&gt;Note:&lt;/strong&gt; The code in this article was tested when it was written, in October 2026: the app side compiles against the iOS 27 SDK, and the server checks were run against Apple's validation guide. App Attest, Apple's guidance and the SDKs all change. Before you rely on any of it, check it against the current documentation and test it again.&lt;/p&gt;&lt;/blockquote&gt;
&lt;p&gt;Here is all of it, in one actor. The protocols at the top stand in for your own networking and keychain code.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-swift"&gt;&lt;span class="k"&gt;import&lt;/span&gt; CryptoKit
&lt;span class="k"&gt;import&lt;/span&gt; DeviceCheck
&lt;span class="k"&gt;import&lt;/span&gt; Foundation

&lt;span class="c"&gt;/// The calls your app makes to your own server. Yours to implement.&lt;/span&gt;
protocol AttestServer: Sendable {
    &lt;span class="k"&gt;func&lt;/span&gt; challenge() async &lt;span class="k"&gt;throws&lt;/span&gt; -&amp;gt; Data
    &lt;span class="k"&gt;func&lt;/span&gt; register(keyID: String, attestation: Data, challenge: Data) async &lt;span class="k"&gt;throws&lt;/span&gt;
    &lt;span class="k"&gt;func&lt;/span&gt; send(&lt;span class="k"&gt;_&lt;/span&gt; request: Data, assertion: Data) async &lt;span class="k"&gt;throws&lt;/span&gt; -&amp;gt; Data
}

&lt;span class="c"&gt;/// Where the key identifier lives. Use a keychain item with&lt;/span&gt;
&lt;span class="c"&gt;/// kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, so it never syncs.&lt;/span&gt;
protocol KeyIDStore: Sendable {
    &lt;span class="k"&gt;func&lt;/span&gt; load() -&amp;gt; String?
    &lt;span class="k"&gt;func&lt;/span&gt; save(&lt;span class="k"&gt;_&lt;/span&gt; keyID: String)
    &lt;span class="k"&gt;func&lt;/span&gt; remove()
}

&lt;span class="c"&gt;/// App Attest on the app side: one key per user per device, attested once.&lt;/span&gt;
actor AppAttest {
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; service = DCAppAttestService.shared
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; server: any AttestServer
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; store: any KeyIDStore

    &lt;span class="k"&gt;init&lt;/span&gt;(server: any AttestServer, store: any KeyIDStore) {
        &lt;span class="k"&gt;self&lt;/span&gt;.server = server
        &lt;span class="k"&gt;self&lt;/span&gt;.store = store
    }

    &lt;span class="c"&gt;/// Run when your server says this install should attest, away from any&lt;/span&gt;
    &lt;span class="c"&gt;/// user flow. Returns false if the device can't attest; the app carries&lt;/span&gt;
    &lt;span class="c"&gt;/// on, and your server decides what that device may do.&lt;/span&gt;
    func attestIfNeeded() async &lt;span class="k"&gt;throws&lt;/span&gt; -&amp;gt; Bool {
        &lt;span class="k"&gt;guard&lt;/span&gt; service.isSupported &lt;span class="k"&gt;else&lt;/span&gt; { &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt; }
        &lt;span class="k"&gt;let&lt;/span&gt; keyID: String
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; saved = store.load() {
            keyID = saved
        } &lt;span class="k"&gt;else&lt;/span&gt; {
            keyID = &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; service.generateKey()
            store.save(keyID)
        }
        &lt;span class="k"&gt;let&lt;/span&gt; challenge = &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; server.challenge()
        &lt;span class="k"&gt;let&lt;/span&gt; hash = Data(SHA256.hash(data: challenge))
        &lt;span class="k"&gt;do&lt;/span&gt; {
            &lt;span class="k"&gt;let&lt;/span&gt; attestation = &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; service.attestKey(keyID, clientDataHash: hash)
            &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; server.register(keyID: keyID, attestation: attestation,
                                      challenge: challenge)
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
        } &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; error &lt;span class="k"&gt;as&lt;/span&gt; DCError &lt;span class="k"&gt;where&lt;/span&gt; error.code == .serverUnavailable {
            &lt;span class="c"&gt;// Keep the key. Try again later, with back-off, using the same key.&lt;/span&gt;
            throw error
        } &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; error &lt;span class="k"&gt;as&lt;/span&gt; DCError {
            &lt;span class="c"&gt;// Any other DeviceCheck error: this key is no good. Forget it, and&lt;/span&gt;
            &lt;span class="c"&gt;// the next attempt starts with a new one.&lt;/span&gt;
            store.remove()
            &lt;span class="k"&gt;throw&lt;/span&gt; error
        }
    }

    &lt;span class="c"&gt;/// Signs one request. The server checks the signature and the counter.&lt;/span&gt;
    func send(&lt;span class="k"&gt;_&lt;/span&gt; action: [String: String]) async &lt;span class="k"&gt;throws&lt;/span&gt; -&amp;gt; Data {
        &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; keyID = store.load() &lt;span class="k"&gt;else&lt;/span&gt; { &lt;span class="k"&gt;throw&lt;/span&gt; DCError(.invalidKey) }
        &lt;span class="k"&gt;var&lt;/span&gt; request = action
        request[&lt;span class="s"&gt;"challenge"&lt;/span&gt;] = &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; server.challenge().base64EncodedString()
        &lt;span class="k"&gt;let&lt;/span&gt; clientData = &lt;span class="k"&gt;try&lt;/span&gt; JSONEncoder().encode(request)
        &lt;span class="k"&gt;let&lt;/span&gt; hash = Data(SHA256.hash(data: clientData))
        &lt;span class="k"&gt;let&lt;/span&gt; assertion = &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; service.generateAssertion(keyID, clientDataHash: hash)
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; server.send(clientData, assertion: assertion)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A few rules come with it, and most of them are in Apple's &lt;a href="https://developer.apple.com/documentation/devicecheck/establishing-your-app-s-integrity"&gt;integration guide&lt;/a&gt; or the WWDC26 session.&lt;/p&gt;
&lt;p&gt;Generate one key per user account per device, or one for the whole app if it has no accounts, and never share a key between users; Apple's guide explains that a shared key makes it hard to spot one compromised device serving many users. Store the identifier in the keychain with a &lt;code&gt;ThisDeviceOnly&lt;/code&gt; accessibility class such as &lt;a href="https://developer.apple.com/documentation/security/ksecattraccessibleafterfirstunlockthisdeviceonly"&gt;&lt;code&gt;kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly&lt;/code&gt;&lt;/a&gt;, because, as the guide puts it, there is no way to get the identifier back later, and the key is useless on any other device anyway.&lt;/p&gt;
&lt;p&gt;Attest once per key, and let your server decide when. Do it away from any user flow, in the background if you can; the &lt;a href="https://developer.apple.com/videos/play/wwdc2026/201/"&gt;WWDC26 session&lt;/a&gt; asks for both. It is easy to end up calling &lt;code&gt;attestKey&lt;/code&gt; from somewhere like a view controller's &lt;code&gt;viewDidLoad&lt;/code&gt;, especially from inside an SDK, and then every launch of every copy of the app goes to Apple's servers. Keep track of whether the key was attested, and don't ask again.&lt;/p&gt;
&lt;p&gt;If &lt;a href="https://developer.apple.com/documentation/devicecheck/dcappattestservice/attestkey(_:clientdatahash:completionhandler:)"&gt;&lt;code&gt;attestKey&lt;/code&gt;&lt;/a&gt; fails with &lt;code&gt;serverUnavailable&lt;/code&gt;, keep the key and try again later, with the same key, backing off exponentially between attempts. For any other DeviceCheck error, throw the identifier away and start over with a new key next time. Both rules are in the &lt;a href="https://developer.apple.com/documentation/devicecheck/establishing-your-app-s-integrity"&gt;integration guide&lt;/a&gt;, and the back-off is from the WWDC26 session.&lt;/p&gt;
&lt;p&gt;Check &lt;code&gt;isSupported&lt;/code&gt; before anything else, and when it is &lt;code&gt;false&lt;/code&gt;, carry on without App Attest and let the server decide what that device is allowed to do. The &lt;a href="https://developer.apple.com/videos/play/wwdc2026/201/"&gt;WWDC26 session&lt;/a&gt; also suggests watching for a rise in unsupported responses from one user, since that can be a sign of tampering.&lt;/p&gt;
&lt;p&gt;Use assertions where they count: signing in, payments, premium content, anything you would not want a script doing. Apple's guide reserves them for sensitive moments, and the WWDC26 session points out that &lt;a href="https://developer.apple.com/documentation/devicecheck/dcappattestservice/generateassertion(_:clientdatahash:completionhandler:)"&gt;generating one&lt;/a&gt; costs CPU. Add the round trip to your server for the challenge, and putting one on every API call is a bad trade.&lt;/p&gt;
&lt;h2 id="the-server-side"&gt;The server side&lt;/h2&gt;
&lt;p&gt;The server does the real work, and it should, because the app cannot be trusted to check itself. Apple's article &lt;a href="https://developer.apple.com/documentation/devicecheck/validating-apps-that-connect-to-your-server"&gt;Validating apps that connect to your server&lt;/a&gt; lists every check, and its &lt;a href="https://developer.apple.com/documentation/devicecheck/attestation-object-validation-guide"&gt;Attestation Object Validation Guide&lt;/a&gt; gives a real attestation object with the expected value at every step. Run your implementation against that guide before you trust it.&lt;/p&gt;
&lt;p&gt;For the attestation, the server:&lt;/p&gt;
&lt;ol&gt;&lt;li&gt;verifies the certificate chain in the attestation statement up to Apple's &lt;a href="https://www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem"&gt;App Attest root certificate&lt;/a&gt;, which Apple publishes on its &lt;a href="https://www.apple.com/certificateauthority/"&gt;certificate authority page&lt;/a&gt;;&lt;/li&gt;&lt;li&gt;computes the nonce as the SHA-256 of the authenticator data followed by the client data hash, and checks it against the leaf certificate's extension &lt;code&gt;1.2.840.113635.100.8.2&lt;/code&gt;;&lt;/li&gt;&lt;li&gt;checks that the hash of the public key in the leaf certificate is the key identifier the app sent;&lt;/li&gt;&lt;li&gt;checks the authenticator data: the hash of your App ID, a counter of 0, the environment in &lt;code&gt;aaguid&lt;/code&gt;, and the key identifier in &lt;code&gt;credentialId&lt;/code&gt;;&lt;/li&gt;&lt;li&gt;on iOS 27 and later, reads the launch validation category and bundle version from the authenticator data's extensions;&lt;/li&gt;&lt;li&gt;stores the public key and the receipt, and associates them with the user and the device.&lt;/li&gt;&lt;/ol&gt;
&lt;p&gt;The attestation follows the &lt;a href="https://www.w3.org/TR/webauthn-2/#sctn-authenticator-data"&gt;WebAuthn authenticator data&lt;/a&gt; format, which is where the field layout below comes from. The certificate chain and the CBOR decoding need an X.509 and a CBOR library; use a maintained one for your server's language. The rest is plain hashing and byte comparison. Here are the authenticator data checks, and the assertion check, in Swift with CryptoKit. I ran the attestation check against Apple's validation guide, and it produces the guide's expected nonce.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-swift"&gt;&lt;span class="k"&gt;import&lt;/span&gt; CryptoKit
&lt;span class="k"&gt;import&lt;/span&gt; Foundation

&lt;span class="c"&gt;/// Reads bytes front to back, one field at a time, so nothing depends on&lt;/span&gt;
&lt;span class="c"&gt;/// where a field sits. Asking for more bytes than are left returns nil.&lt;/span&gt;
struct ByteReader {
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; bytes: ArraySlice&amp;lt;UInt8&amp;gt;

    &lt;span class="k"&gt;init&lt;/span&gt;(&lt;span class="k"&gt;_&lt;/span&gt; data: Data) {
        bytes = ArraySlice(data)
    }

    mutating &lt;span class="k"&gt;func&lt;/span&gt; take(&lt;span class="k"&gt;_&lt;/span&gt; count: Int) -&amp;gt; Data? {
        &lt;span class="k"&gt;guard&lt;/span&gt; count &amp;gt;= &lt;span class="n"&gt;0&lt;/span&gt;, bytes.count &amp;gt;= count &lt;span class="k"&gt;else&lt;/span&gt; { &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;nil&lt;/span&gt; }
        &lt;span class="k"&gt;defer&lt;/span&gt; { bytes = bytes.dropFirst(count) }
        &lt;span class="k"&gt;return&lt;/span&gt; Data(bytes.prefix(count))
    }

    &lt;span class="c"&gt;/// An unsigned big-endian integer of `count` bytes.&lt;/span&gt;
    mutating &lt;span class="k"&gt;func&lt;/span&gt; uint(&lt;span class="k"&gt;_&lt;/span&gt; count: Int) -&amp;gt; UInt64? {
        take(count)?.reduce(&lt;span class="n"&gt;0&lt;/span&gt;) { $&lt;span class="n"&gt;0&lt;/span&gt; &amp;lt;&amp;lt; &lt;span class="n"&gt;8&lt;/span&gt; | UInt64($&lt;span class="n"&gt;1&lt;/span&gt;) }
    }
}

&lt;span class="c"&gt;/// WebAuthn authenticator data, as App Attest uses it.&lt;/span&gt;
struct AuthenticatorData {
    &lt;span class="c"&gt;/// Field sizes, as the WebAuthn specification defines them.&lt;/span&gt;
    enum Size {
        &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; rpIDHash = &lt;span class="n"&gt;32&lt;/span&gt;
        &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; flags = &lt;span class="n"&gt;1&lt;/span&gt;
        &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; counter = &lt;span class="n"&gt;4&lt;/span&gt;
        &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; aaguid = &lt;span class="n"&gt;16&lt;/span&gt;
        &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; credentialIDLength = &lt;span class="n"&gt;2&lt;/span&gt;
    }

    &lt;span class="c"&gt;/// Flag bits from the same section of the specification.&lt;/span&gt;
    enum Flag {
        &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; attestedCredentialData: UInt8 = &lt;span class="n"&gt;0x40&lt;/span&gt;
        &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; extensionData: UInt8 = &lt;span class="n"&gt;0x80&lt;/span&gt;
    }

    &lt;span class="k"&gt;let&lt;/span&gt; rpIDHash: Data
    &lt;span class="k"&gt;let&lt;/span&gt; flags: UInt8
    &lt;span class="k"&gt;let&lt;/span&gt; counter: UInt32
    &lt;span class="k"&gt;let&lt;/span&gt; aaguid: Data?         &lt;span class="c"&gt;// present when the flags say so: attestations&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; credentialID: Data?

    &lt;span class="k"&gt;init&lt;/span&gt;?(&lt;span class="k"&gt;_&lt;/span&gt; data: Data) {
        &lt;span class="k"&gt;var&lt;/span&gt; reader = ByteReader(data)
        &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; rpIDHash = reader.take(Size.rpIDHash),
              &lt;span class="k"&gt;let&lt;/span&gt; flags = reader.take(Size.flags)?.first,
              &lt;span class="k"&gt;let&lt;/span&gt; counter = reader.uint(Size.counter)
        &lt;span class="k"&gt;else&lt;/span&gt; { &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;nil&lt;/span&gt; }
        &lt;span class="k"&gt;self&lt;/span&gt;.rpIDHash = rpIDHash
        &lt;span class="k"&gt;self&lt;/span&gt;.flags = flags
        &lt;span class="k"&gt;self&lt;/span&gt;.counter = UInt32(counter)
        &lt;span class="c"&gt;// Read the attested credential data only if the flags say it is&lt;/span&gt;
        &lt;span class="c"&gt;// there; never assume it.&lt;/span&gt;
        if flags &amp;amp; Flag.attestedCredentialData != &lt;span class="n"&gt;0&lt;/span&gt; {
            &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; aaguid = reader.take(Size.aaguid),
                  &lt;span class="k"&gt;let&lt;/span&gt; length = reader.uint(Size.credentialIDLength),
                  &lt;span class="k"&gt;let&lt;/span&gt; credentialID = reader.take(Int(length))
            &lt;span class="k"&gt;else&lt;/span&gt; { &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;nil&lt;/span&gt; }
            &lt;span class="k"&gt;self&lt;/span&gt;.aaguid = aaguid
            &lt;span class="k"&gt;self&lt;/span&gt;.credentialID = credentialID
        } &lt;span class="k"&gt;else&lt;/span&gt; {
            aaguid = &lt;span class="k"&gt;nil&lt;/span&gt;
            credentialID = &lt;span class="k"&gt;nil&lt;/span&gt;
        }
        &lt;span class="c"&gt;// What follows is CBOR: the COSE public key, then the extensions.&lt;/span&gt;
        &lt;span class="c"&gt;// Apple's own sample object carries extensions without setting&lt;/span&gt;
        &lt;span class="c"&gt;// Flag.extensionData, so don't use that flag to decide whether to&lt;/span&gt;
        &lt;span class="c"&gt;// look for them. Decode whatever follows the key, with a CBOR library.&lt;/span&gt;
    }
}

&lt;span class="k"&gt;enum&lt;/span&gt; Environment {
    &lt;span class="k"&gt;case&lt;/span&gt; development, production

    &lt;span class="c"&gt;/// The aaguid values this environment accepts.&lt;/span&gt;
    var aaguids: Set&amp;lt;Data&amp;gt; {
        &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt; {
        &lt;span class="k"&gt;case&lt;/span&gt; .production:
            &lt;span class="k"&gt;return&lt;/span&gt; [Data(&lt;span class="s"&gt;"appattest"&lt;/span&gt;.utf8) + Data(repeating: &lt;span class="n"&gt;0&lt;/span&gt;, count: &lt;span class="n"&gt;7&lt;/span&gt;)]
        &lt;span class="k"&gt;case&lt;/span&gt; .development:
            &lt;span class="c"&gt;// Apple's pages give two different development values; accept both&lt;/span&gt;
            &lt;span class="c"&gt;// here, and log which one your devices actually send.&lt;/span&gt;
            return [Data(&lt;span class="s"&gt;"appattestdevelop"&lt;/span&gt;.utf8), Data(&lt;span class="s"&gt;"appattestsandbox"&lt;/span&gt;.utf8)]
        }
    }
}

&lt;span class="c"&gt;/// The attestation checks that don't need an X.509 or CBOR library. The&lt;/span&gt;
&lt;span class="c"&gt;/// certificate chain, and the nonce in the leaf certificate's extension&lt;/span&gt;
&lt;span class="c"&gt;/// 1.2.840.113635.100.8.2, need both; use a maintained library for those.&lt;/span&gt;
&lt;span class="c"&gt;///&lt;/span&gt;
&lt;span class="c"&gt;/// `clientDataHash` must be exactly the bytes the app passed to&lt;/span&gt;
&lt;span class="c"&gt;/// `attestKey`. If the app hashed the challenge, hash it here the same way.&lt;/span&gt;
func checkAttestation(authData: Data, keyID: String, clientDataHash: Data,
                      appID: String, environment: Environment) -&amp;gt; Data? {
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; auth = AuthenticatorData(authData),
          &lt;span class="k"&gt;let&lt;/span&gt; aaguid = auth.aaguid,
          &lt;span class="k"&gt;let&lt;/span&gt; credentialID = auth.credentialID,
          &lt;span class="k"&gt;let&lt;/span&gt; keyIDBytes = Data(base64Encoded: keyID) &lt;span class="k"&gt;else&lt;/span&gt; { &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;nil&lt;/span&gt; }
    &lt;span class="k"&gt;guard&lt;/span&gt; auth.rpIDHash == Data(SHA256.hash(data: Data(appID.utf8))),
          auth.counter == &lt;span class="n"&gt;0&lt;/span&gt;,
          environment.aaguids.contains(aaguid),
          credentialID == keyIDBytes &lt;span class="k"&gt;else&lt;/span&gt; { &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;nil&lt;/span&gt; }
    &lt;span class="c"&gt;// The value the leaf certificate's nonce extension must equal.&lt;/span&gt;
    return Data(SHA256.hash(data: authData + clientDataHash))
}

&lt;span class="c"&gt;/// Checks one assertion against the public key stored at attestation time.&lt;/span&gt;
&lt;span class="c"&gt;/// Returns the new counter to store, or nil to reject the request.&lt;/span&gt;
func checkAssertion(signature: Data, authData: Data, clientData: Data,
                    publicKey: P256.Signing.PublicKey, appID: String,
                    lastCounter: UInt32) -&amp;gt; UInt32? {
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; auth = AuthenticatorData(authData),
          auth.rpIDHash == Data(SHA256.hash(data: Data(appID.utf8))),
          auth.counter &amp;gt; lastCounter,
          &lt;span class="k"&gt;let&lt;/span&gt; ecdsa = &lt;span class="k"&gt;try&lt;/span&gt;? P256.Signing.ECDSASignature(derRepresentation: signature)
    &lt;span class="k"&gt;else&lt;/span&gt; { &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;nil&lt;/span&gt; }
    &lt;span class="k"&gt;let&lt;/span&gt; clientDataHash = Data(SHA256.hash(data: clientData))
    &lt;span class="k"&gt;let&lt;/span&gt; nonce = Data(SHA256.hash(data: authData + clientDataHash))
    &lt;span class="k"&gt;guard&lt;/span&gt; publicKey.isValidSignature(ecdsa, &lt;span class="k"&gt;for&lt;/span&gt;: nonce) &lt;span class="k"&gt;else&lt;/span&gt; { &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;nil&lt;/span&gt; }
    &lt;span class="k"&gt;return&lt;/span&gt; auth.counter
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For an assertion, the server checks the signature over the nonce with the stored public key, the App ID hash, that the challenge in the client data is the one it handed out, and that the counter is higher than the last one it saw for that key. Then it stores the new counter. These are the steps in Apple's &lt;a href="https://developer.apple.com/documentation/devicecheck/validating-apps-that-connect-to-your-server"&gt;validation article&lt;/a&gt;. A counter that stays the same or goes backwards means a replay, or, as the WWDC26 session puts it, a copy of the app that does not know where the real one is up to.&lt;/p&gt;
&lt;h3 id="libraries-in-other-languages"&gt;Libraries in other languages&lt;/h3&gt;
&lt;p&gt;If your server is not written in Swift, there are open-source libraries that do all of this, certificate chain and CBOR included. I have not tested any of them. Read the code, check it against Apple's validation guide the same way, and look at how recently it was maintained before you trust it with anything that matters.&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;Node.js and TypeScript: &lt;a href="https://github.com/uebelack/node-app-attest"&gt;node-app-attest&lt;/a&gt; and &lt;a href="https://github.com/srinivas1729/appattest-checker-node"&gt;appattest-checker-node&lt;/a&gt;&lt;/li&gt;&lt;li&gt;Python: &lt;a href="https://github.com/dreipol/pyattest"&gt;pyattest&lt;/a&gt;, which also handles Android's Play Integrity&lt;/li&gt;&lt;li&gt;Go: &lt;a href="https://github.com/splitsecure/go-app-attest"&gt;go-app-attest&lt;/a&gt;&lt;/li&gt;&lt;li&gt;Kotlin and Java: &lt;a href="https://github.com/veehaitch/devicecheck-appattest"&gt;devicecheck-appattest&lt;/a&gt;, &lt;a href="https://github.com/webauthn4j/webauthn4j"&gt;WebAuthn4J&lt;/a&gt;, which supports App Attest alongside WebAuthn, and &lt;a href="https://github.com/a-sit-plus/warden-supreme"&gt;Warden Supreme&lt;/a&gt;, which covers Android key attestation as well&lt;/li&gt;&lt;li&gt;Rust: &lt;a href="https://github.com/TheDhejavu/appattest-rs"&gt;appattest-rs&lt;/a&gt;&lt;/li&gt;&lt;li&gt;PHP: &lt;a href="https://github.com/Oire/app-attest"&gt;app-attest&lt;/a&gt;&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;If you would rather not run the verification yourself, &lt;a href="https://firebase.google.com/docs/app-check/ios/app-attest-provider"&gt;Firebase App Check&lt;/a&gt; can use App Attest as its provider and do it for you.&lt;/p&gt;
&lt;h2 id="the-things-that-trip-people-up"&gt;The things that trip people up&lt;/h2&gt;
&lt;h3 id="the-nonce-is-built-from-exactly-what-the-app-passed"&gt;The nonce is built from exactly what the app passed&lt;/h3&gt;
&lt;p&gt;The nonce is the SHA-256 of the authenticator data followed by whatever bytes the app gave &lt;code&gt;attestKey&lt;/code&gt; as &lt;code&gt;clientDataHash&lt;/code&gt;. Apple's &lt;a href="https://developer.apple.com/documentation/devicecheck/validating-apps-that-connect-to-your-server"&gt;steps&lt;/a&gt; have the app pass the SHA-256 of the challenge, and the server has to hash it the same way. The sample object in the &lt;a href="https://developer.apple.com/documentation/devicecheck/attestation-object-validation-guide"&gt;validation guide&lt;/a&gt; was made by passing the challenge itself, so if you test against the guide, append the challenge as it is. Either works, as long as both sides agree.&lt;/p&gt;
&lt;h3 id="extensions-without-the-extension-flag"&gt;Extensions without the extension flag&lt;/h3&gt;
&lt;p&gt;In WebAuthn, a flag in the authenticator data says whether extensions follow. The sample object in Apple's &lt;a href="https://developer.apple.com/documentation/devicecheck/attestation-object-validation-guide"&gt;validation guide&lt;/a&gt; sets only the attested credential data flag (&lt;code&gt;0x40&lt;/code&gt;), yet it carries the iOS 27 extensions, &lt;code&gt;apple_validation_category_01&lt;/code&gt; and &lt;code&gt;apple_bundle_version_01&lt;/code&gt;. A server that relies on the extension flag will miss them. Decode whatever follows the public key instead.&lt;/p&gt;
&lt;h3 id="two-names-for-the-development-environment"&gt;Two names for the development environment&lt;/h3&gt;
&lt;p&gt;The &lt;a href="https://developer.apple.com/documentation/devicecheck/validating-apps-that-connect-to-your-server"&gt;validation article&lt;/a&gt; says a key attested in development carries &lt;code&gt;appattestdevelop&lt;/code&gt; in &lt;code&gt;aaguid&lt;/code&gt;, and the article on &lt;a href="https://developer.apple.com/documentation/devicecheck/preparing-to-use-the-app-attest-service"&gt;preparing to use the service&lt;/a&gt; says &lt;code&gt;appattestsandbox&lt;/code&gt;. Accept both when your server runs in development, log which one your devices send, and never accept either in production, where the value is &lt;code&gt;appattest&lt;/code&gt; followed by seven zero bytes.&lt;/p&gt;
&lt;h3 id="development-and-production-do-not-mix"&gt;Development and production do not mix&lt;/h3&gt;
&lt;p&gt;Keys and receipts from one environment are useless in the other. During development the app uses the sandbox unless you set the &lt;a href="https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.developer.devicecheck.appattest-environment"&gt;App Attest environment entitlement&lt;/a&gt; to &lt;code&gt;production&lt;/code&gt;. Once the app is on TestFlight or the App Store it always uses production. Test in the sandbox, because, as Apple's &lt;a href="https://developer.apple.com/documentation/devicecheck/preparing-to-use-the-app-attest-service"&gt;onboarding guidance&lt;/a&gt; explains, production keeps a count of keys per device that you cannot reset.&lt;/p&gt;
&lt;h3 id="keys-do-not-survive-reinstalling"&gt;Keys do not survive reinstalling&lt;/h3&gt;
&lt;p&gt;A key lasts through app updates, but not through a reinstall, a restore from a backup (iCloud included), or a move to a new device. The &lt;a href="https://developer.apple.com/documentation/devicecheck/establishing-your-app-s-integrity"&gt;integration guide&lt;/a&gt; and the &lt;a href="https://developer.apple.com/videos/play/wwdc2026/201/"&gt;WWDC26 session&lt;/a&gt; both say so. So one user will collect several keys over time, one per device and one per reinstall. Accept new keys for an existing user, and do not throw away the old ones straight away.&lt;/p&gt;
&lt;h3 id="where-it-runs"&gt;Where it runs&lt;/h3&gt;
&lt;p&gt;The &lt;a href="https://developer.apple.com/videos/play/wwdc2026/201/"&gt;WWDC26 session&lt;/a&gt; and the &lt;a href="https://developer.apple.com/documentation/devicecheck/establishing-your-app-s-integrity"&gt;integration guide&lt;/a&gt; list Action and SSO extensions as supported, along with watchOS, and macOS from version 27. Check &lt;code&gt;isSupported&lt;/code&gt; at run time and believe what it says on the device in front of you. An App Clip attests with the full app's App ID, and should store its key identifier in a container the full app can read, so the full app uses the same key.&lt;/p&gt;
&lt;h3 id="the-mac"&gt;The Mac&lt;/h3&gt;
&lt;p&gt;The reference page for &lt;a href="https://developer.apple.com/documentation/devicecheck/dcappattestservice/issupported"&gt;&lt;code&gt;isSupported&lt;/code&gt;&lt;/a&gt; was written before the Mac support arrived. It says &lt;code&gt;isSupported&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt; on a Mac, for Mac Catalyst apps and for iPhone and iPad apps running on Apple silicon too. That was true until macOS 27. From macOS 27, the WWDC26 session says App Attest works on the Mac, with a condition: every key the Mac generates is bound to a policy that requires full security mode and System Integrity Protection. Apple has not said whether Mac Catalyst apps and iPhone apps on a Mac are included, so ask &lt;code&gt;isSupported&lt;/code&gt; there rather than assume.&lt;/p&gt;
&lt;p&gt;A Mac with System Integrity Protection turned off can still produce an attestation, so don't take a successful attestation as proof that the Mac is in full security mode. The proof is the key's access policy, which the leaf certificate carries under OID &lt;code&gt;1.2.840.113635.100.8.6&lt;/code&gt;. The &lt;a href="https://developer.apple.com/documentation/devicecheck/validating-apps-that-connect-to-your-server"&gt;validation article&lt;/a&gt; gives the one value to accept, and says to trust a Mac's attested key only for exactly that value. One more Mac difference comes from the same article: the &lt;code&gt;RP ID&lt;/code&gt; hash is computed over the app's designated code signing requirement instead of its bundle identifier, as &lt;a href="https://developer.apple.com/documentation/technotes/tn3127-inside-code-signing-requirements"&gt;TN3127&lt;/a&gt; describes, so check your server computes the right one for Mac clients.&lt;/p&gt;
&lt;h3 id="turning-it-on-for-an-app-with-millions-of-users"&gt;Turning it on for an app with millions of users&lt;/h3&gt;
&lt;p&gt;Attestation goes through Apple's servers, and they throttle an app that sends too much at once. Apple's &lt;a href="https://developer.apple.com/documentation/devicecheck/preparing-to-use-the-app-attest-service"&gt;onboarding guidance&lt;/a&gt; is to ramp up gradually and uniformly, no more than 10 million users a day per app, and to stay under 100 attestation requests a second across all installations. Ship the code with attestation switched off, and have your server turn it on for a growing share of your users. After the first rollout, only new users, new devices and reinstalls attest, and for most apps that traffic does not cause trouble.&lt;/p&gt;
&lt;p&gt;Most is not all. If your app goes viral overnight, or a feature launch sends a few million new users your way in a day, that surge is a rollout too, whether you planned it or not. Because your server decides when each copy of the app attests, as the &lt;a href="https://developer.apple.com/videos/play/wwdc2026/201/"&gt;WWDC26 session&lt;/a&gt; asks, it can let the new users in and pace their attestations to stay under the limit.&lt;/p&gt;
&lt;p&gt;The surge you are most likely to cause yourself is a server failure. Say your server loses its record of which keys it has seen, through an outage, a botched migration or a restore from an old backup. The tempting fix is to tell every copy of the app to attest again. That is your whole user base calling Apple at once, on keys that were already attested, and it is exactly the traffic the onboarding guidance tells you to avoid. Keep the attestation records as safe as anything else you can't rebuild, and if you ever do need to collect attestations again, ramp up the way you did the first time.&lt;/p&gt;
&lt;h3 id="assertions-from-several-requests-at-once"&gt;Assertions from several requests at once&lt;/h3&gt;
&lt;p&gt;The counter goes up with every assertion. If the app signs two requests at the same time and they reach the server in the other order, the server sees the counter go backwards, and rejects a perfectly good request. There are two ways out, both suggested in &lt;a href="https://www.theswift.dev/posts/validate-real-apps-with-app-attest/"&gt;The Swift Dev's walk-through&lt;/a&gt;: serialize the signed requests in the app, so that one assertion is made and sent before the next one starts, or have the app retry a rejected request with a fresh challenge, which gets a new assertion with a higher counter.&lt;/p&gt;
&lt;h3 id="never-lock-people-out-because-attestation-failed"&gt;Never lock people out because attestation failed&lt;/h3&gt;
&lt;p&gt;Do not make your app depend on attestation succeeding. Networks fail. Apple's service can be slow or unreachable for a while, as any service on the internet can, which is what the &lt;code&gt;serverUnavailable&lt;/code&gt; error is for. Keys get rotated by every reinstall and restore, and some devices cannot attest at all. If your app refuses to work whenever an attestation fails or isn't there, then the day attestation has a bad hour is the day your app stops working for everyone, honest users first. That is an outage you built yourself, and it is no use blaming Apple for it afterwards.&lt;/p&gt;
&lt;p&gt;A failed or missing attestation is a reason to look closer, never a reason on its own to lock someone out. Apple's advice in the &lt;a href="https://developer.apple.com/videos/play/wwdc2026/201/"&gt;WWDC26 session&lt;/a&gt; is to degrade gracefully: limit what that user can do, watch them more closely, and leave the decision to your risk assessment. Keep everything that doesn't need App Attest working without it.&lt;/p&gt;
&lt;h2 id="the-fraud-metric"&gt;The fraud metric&lt;/h2&gt;
&lt;p&gt;A device with a compromised operating system can still produce valid attestations, and an attacker can use one such device to attest on behalf of modified copies of your app running on other devices. The &lt;a href="https://developer.apple.com/documentation/devicecheck/assessing-fraud-risk"&gt;fraud metric&lt;/a&gt; is how your server notices.&lt;/p&gt;
&lt;p&gt;To get the metric, your server sends the receipt that came with an attestation to Apple's App Attest data server. The request is authenticated with a JWT made the &lt;a href="https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns"&gt;same way as for APNs&lt;/a&gt;. Apple's server answers with a new receipt, and that new receipt holds the metric: an approximate count of the keys attested for your app on that device in the last 30 days. Store the new receipt in place of the old one, because the next request uses the latest receipt.&lt;/p&gt;
&lt;p&gt;Expect the count to be small. Reinstalls and restores push the count up a little; a device that has attested hundreds of keys is almost certainly attesting for somebody else.&lt;/p&gt;
&lt;p&gt;The count goes stale, so your server has to ask again from time to time. Each receipt has two dates in it: the earliest time your server may ask for a new count, and the time after which Apple's server may refuse to. A request made before the first date gets a &lt;code&gt;304&lt;/code&gt;, and a receipt left past the second date may stop working, so schedule the requests between the two.&lt;/p&gt;
&lt;p&gt;As with every other signal in this article, treat a high count as a reason to look closer, and decide what to do about a suspicious device the same way you decide about any other fraud signal.&lt;/p&gt;
&lt;h2 id="further-reading"&gt;Further reading&lt;/h2&gt;
&lt;p&gt;The WWDC21 session &lt;a href="https://developer.apple.com/videos/play/wwdc2021/10244/"&gt;Mitigate fraud with App Attest and DeviceCheck&lt;/a&gt; is older, but it covers DeviceCheck too, which the WWDC26 session does not. Outside Apple, &lt;a href="https://www.theswift.dev/posts/validate-real-apps-with-app-attest/"&gt;Validate Real Apps With App Attest&lt;/a&gt; on The Swift Dev is a recent end-to-end walk-through, and Bruno Rocha's &lt;a href="https://swiftrocks.com/app-attest-apple-protect-ios-jailbreak"&gt;App Attest&lt;/a&gt; on SwiftRocks is from 2020, but its explanation of what App Attest does and does not protect against still holds.&lt;/p&gt;
&lt;h2 id="sources"&gt;Sources&lt;/h2&gt;
&lt;ul&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/videos/play/wwdc2026/201/"&gt;Secure your apps with App Attest&lt;/a&gt;, WWDC26&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/videos/play/wwdc2021/10244/"&gt;Mitigate fraud with App Attest and DeviceCheck&lt;/a&gt;, WWDC21&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/devicecheck/establishing-your-app-s-integrity"&gt;Establishing your app's integrity&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/devicecheck/validating-apps-that-connect-to-your-server"&gt;Validating apps that connect to your server&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/devicecheck/attestation-object-validation-guide"&gt;Attestation Object Validation Guide&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/devicecheck/preparing-to-use-the-app-attest-service"&gt;Preparing to use the App Attest service&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/devicecheck/assessing-fraud-risk"&gt;Assessing fraud risk&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.developer.devicecheck.appattest-environment"&gt;App Attest environment entitlement&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/devicecheck/dcappattestservice/issupported"&gt;&lt;code&gt;isSupported&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://developer.apple.com/documentation/devicecheck/dcappattestservice/attestkey(_:clientdatahash:completionhandler:)"&gt;&lt;code&gt;attestKey(_:clientDataHash:completionHandler:)&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://developer.apple.com/documentation/devicecheck/dcappattestservice/generateassertion(_:clientdatahash:completionhandler:)"&gt;&lt;code&gt;generateAssertion(_:clientDataHash:completionHandler:)&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/technotes/tn3127-inside-code-signing-requirements"&gt;TN3127: Inside Code Signing: Requirements&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/security/ksecattraccessibleafterfirstunlockthisdeviceonly"&gt;&lt;code&gt;kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns"&gt;Establishing a token-based connection to APNs&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem"&gt;Apple App Attestation Root CA&lt;/a&gt;, from &lt;a href="https://www.apple.com/certificateauthority/"&gt;Apple PKI&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://www.w3.org/TR/webauthn-2/#sctn-authenticator-data"&gt;Web Authentication, Level 2: authenticator data&lt;/a&gt;, W3C&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/forums/thread/749242"&gt;How to block iOS App to run on a Rooted device?&lt;/a&gt;, Apple Developer Forums, an Apple engineer's reply&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/forums/thread/692391"&gt;How to test anti jailbreak&lt;/a&gt;, Apple Developer Forums, Apple DTS's reply&lt;/li&gt;&lt;li&gt;&lt;a href="https://mas.owasp.org/MASWE/MASVS-STORAGE/MASWE-0004/"&gt;MASWE-0004: Sensitive Data Hardcoded in the App Package&lt;/a&gt;, OWASP&lt;/li&gt;&lt;li&gt;&lt;a href="https://mas.owasp.org/MASVS/11-MASVS-RESILIENCE/"&gt;MASVS-RESILIENCE: Resilience Against Reverse Engineering and Tampering&lt;/a&gt;, OWASP&lt;/li&gt;&lt;li&gt;&lt;a href="https://mas.owasp.org/MASWE/MASVS-RESILIENCE/MASWE-0051/"&gt;MASWE-0051: Root/Jailbreak Detection Not Implemented&lt;/a&gt;, OWASP&lt;/li&gt;&lt;li&gt;&lt;a href="https://www.theswift.dev/posts/validate-real-apps-with-app-attest/"&gt;Validate Real Apps With App Attest&lt;/a&gt;, The Swift Dev&lt;/li&gt;&lt;li&gt;&lt;a href="https://swiftrocks.com/app-attest-apple-protect-ios-jailbreak"&gt;App Attest: How to prevent an iOS app's APIs from being abused&lt;/a&gt;, SwiftRocks&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;Server libraries, untested by me:&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;&lt;a href="https://github.com/uebelack/node-app-attest"&gt;node-app-attest&lt;/a&gt;, &lt;a href="https://github.com/srinivas1729/appattest-checker-node"&gt;appattest-checker-node&lt;/a&gt; (Node.js)&lt;/li&gt;&lt;li&gt;&lt;a href="https://github.com/dreipol/pyattest"&gt;pyattest&lt;/a&gt; (Python)&lt;/li&gt;&lt;li&gt;&lt;a href="https://github.com/splitsecure/go-app-attest"&gt;go-app-attest&lt;/a&gt; (Go)&lt;/li&gt;&lt;li&gt;&lt;a href="https://github.com/veehaitch/devicecheck-appattest"&gt;devicecheck-appattest&lt;/a&gt;, &lt;a href="https://github.com/webauthn4j/webauthn4j"&gt;WebAuthn4J&lt;/a&gt;, &lt;a href="https://github.com/a-sit-plus/warden-supreme"&gt;Warden Supreme&lt;/a&gt; (Kotlin and Java)&lt;/li&gt;&lt;li&gt;&lt;a href="https://github.com/TheDhejavu/appattest-rs"&gt;appattest-rs&lt;/a&gt; (Rust)&lt;/li&gt;&lt;li&gt;&lt;a href="https://github.com/Oire/app-attest"&gt;app-attest&lt;/a&gt; (PHP)&lt;/li&gt;&lt;li&gt;&lt;a href="https://firebase.google.com/docs/app-check/ios/app-attest-provider"&gt;Firebase App Check with App Attest&lt;/a&gt;, a hosted alternative&lt;/li&gt;&lt;/ul&gt;</content:encoded></item><item><title>How an iOS app extension talks to its containing app</title><link>https://7strikes.dev/articles/app-extension-ipc/</link><guid isPermaLink="true">https://7strikes.dev/articles/app-extension-ipc/</guid><pubDate>Mon, 19 Oct 2026 00:00:00 +0000</pubDate><category>Background execution</category><category>Push notifications</category><category>Core Location and Core Motion</category><description>For most extensions the answer is that it can't, because the app usually isn't running. What works is writing to the App Group container and letting the app read it when it runs. How to do that properly, the few exceptions, and why you may not need any of it.</description><content:encoded>&lt;p&gt;Most of the time, it can't. There's no open channel between an extension and its containing app, and the app usually isn't even running while the extension is. What works for nearly every extension, Notification Service and Location Push Service extensions included, is to write what the app needs into the App Group container and have the app read it the next time it runs.&lt;/p&gt;
&lt;p&gt;This article covers how to do that properly, the handful of cases where you can do more, and before any of it, whether you need to reach the app at all.&lt;/p&gt;
&lt;h2 id="do-you-need-the-app"&gt;Do you need the app?&lt;/h2&gt;
&lt;p&gt;The usual reason for wanting to call into the app is that the app already has the code. It parses the payload, updates the database, talks to the server, and nobody wants a second copy of all that in the extension. Sometimes the worry is that the existing code won't fit in the limits of extensions. Extensions get much less memory than apps (&lt;a href="https://developer.apple.com/library/archive/documentation/General/Conceptual/ExtensibilityPG/ExtensionCreation.html"&gt;Apple's extension guide&lt;/a&gt; calls the limits "significantly lower"), and a notification service extension gets &lt;a href="https://developer.apple.com/documentation/usernotifications/unnotificationserviceextension/didreceive(_:withcontenthandler:)"&gt;no more than 30 seconds&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The first problem doesn't need the app. Put the code in a framework or a Swift package that both targets link, and the extension runs the same code in its own process. [The extension guide] (https://developer.apple.com/library/archive/documentation/General/Conceptual/ExtensibilityPG/ExtensionScenarios.html) recommends exactly that, with "Require Only App-Extension-Safe API" turned on for the framework/package. Anything in the shared code that extensions can't use gets marked, and the compiler keeps the extensions away from it:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-swift"&gt;&lt;span class="k"&gt;import&lt;/span&gt; UIKit

&lt;span class="c"&gt;/// Shared by the app and its extensions.&lt;/span&gt;
enum Thumbnails {
    &lt;span class="c"&gt;/// Fine everywhere: plain image work.&lt;/span&gt;
    static &lt;span class="k"&gt;func&lt;/span&gt; make(from data: Data, side: CGFloat) -&amp;gt; UIImage? {
        &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; image = UIImage(data: data) &lt;span class="k"&gt;else&lt;/span&gt; { &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;nil&lt;/span&gt; }
        &lt;span class="k"&gt;let&lt;/span&gt; size = CGSize(width: side, height: side)
        &lt;span class="k"&gt;return&lt;/span&gt; UIGraphicsImageRenderer(size: size).image { &lt;span class="k"&gt;_&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
            image.draw(&lt;span class="k"&gt;in&lt;/span&gt;: CGRect(origin: .zero, size: size))
        }
    }

    &lt;span class="c"&gt;/// Uses UIApplication, so it's for the app only.&lt;/span&gt;
    &lt;span class="a"&gt;@available&lt;/span&gt;(iOSApplicationExtension, unavailable)
    &lt;span class="a"&gt;@MainActor&lt;/span&gt;
    &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt; makeInBackground(&lt;span class="k"&gt;_&lt;/span&gt; work: &lt;span class="a"&gt;@escaping&lt;/span&gt; &lt;span class="a"&gt;@Sendable&lt;/span&gt; () -&amp;gt; Void) {
        &lt;span class="k"&gt;let&lt;/span&gt; task = UIApplication.shared.beginBackgroundTask(withName: &lt;span class="s"&gt;"Thumbs"&lt;/span&gt;)
        work()
        UIApplication.shared.endBackgroundTask(task)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Call &lt;code&gt;makeInBackground&lt;/code&gt; from an extension and the build stops with "'makeInBackground' is unavailable in application extensions for iOS".&lt;/p&gt;
&lt;p&gt;For the second problem, do only what can't wait. A notification service extension has to decrypt the text before it's shown; rebuilding the chat index can happen later. A location push extension wants to update a server, creating a path can be done when the app is visible.&lt;/p&gt;
&lt;p&gt;Instead, leave the app a note in the shared container saying what's left, and let the app do it when it next runs, or schedule a &lt;a href="https://developer.apple.com/documentation/backgroundtasks/bgprocessingtaskrequest"&gt;&lt;code&gt;BGProcessingTask&lt;/code&gt;&lt;/a&gt; for it then. The work happens later, with the app's memory and the app's time.&lt;/p&gt;
&lt;p&gt;If neither of those covers it and the app really does have to hear from the extension, here are your options (depending on the extension type you have)&lt;/p&gt;
&lt;h2 id="the-mailbox"&gt;The mailbox&lt;/h2&gt;
&lt;p&gt;The extension writes each message as its own file in the App Group container, then posts a Darwin notification. If the app is running, it hears the notification and reads the files. Running or not, it also reads them at launch and every time it becomes active, so a message written while the app was suspended or not running is still there when it looks.&lt;/p&gt;
&lt;p&gt;The notification is only there to make it quicker. If it never arrives, nothing is lost, because the next launch or activation picks the message up anyway. &lt;a href="https://github.com/griddynamics-archive/MMWormhole"&gt;MMWormhole made this pattern popular years ago&lt;/a&gt;, and its README makes the same point: the notification reaches the app only if the app is awake.&lt;/p&gt;
&lt;p&gt;Both targets share this code:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-swift"&gt;&lt;span class="k"&gt;import&lt;/span&gt; Foundation
&lt;span class="k"&gt;import&lt;/span&gt; notify

&lt;span class="c"&gt;/// Observes one Darwin notification name until released.&lt;/span&gt;
final &lt;span class="k"&gt;class&lt;/span&gt; DarwinObserver {
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; token: Int32 = NOTIFY_TOKEN_INVALID

    &lt;span class="c"&gt;/// Calls `handler` on the main queue each time `name` is posted.&lt;/span&gt;
    &lt;span class="k"&gt;init&lt;/span&gt;(&lt;span class="k"&gt;_&lt;/span&gt; name: String, handler: &lt;span class="a"&gt;@escaping&lt;/span&gt; &lt;span class="a"&gt;@MainActor&lt;/span&gt; () -&amp;gt; Void) {
        notify_register_dispatch(name, &amp;amp;token, .main) { &lt;span class="k"&gt;_&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
            MainActor.assumeIsolated { handler() }
        }
    }

    &lt;span class="k"&gt;deinit&lt;/span&gt; {
        notify_cancel(token)
    }
}

&lt;span class="c"&gt;/// One message, as the extension wrote it.&lt;/span&gt;
struct Envelope&amp;lt;Body: Codable &amp;amp; Sendable&amp;gt;: Codable, Sendable {
    &lt;span class="k"&gt;let&lt;/span&gt; sent: Date
    &lt;span class="k"&gt;let&lt;/span&gt; body: Body
}

&lt;span class="c"&gt;/// A directory of message files in the App Group container.&lt;/span&gt;
struct Mailbox: Sendable {
    &lt;span class="c"&gt;/// The Darwin notification that says a message has arrived.&lt;/span&gt;
    static &lt;span class="k"&gt;let&lt;/span&gt; ping = &lt;span class="s"&gt;"group.com.example.app.mailbox"&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; inbox: URL
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; staging: URL

    &lt;span class="c"&gt;/// The mailbox in `appGroup`, or nil if the group is not entitled.&lt;/span&gt;
    &lt;span class="k"&gt;init&lt;/span&gt;?(appGroup: String) {
        &lt;span class="k"&gt;let&lt;/span&gt; files = FileManager.default
        &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; root = files.containerURL(
            forSecurityApplicationGroupIdentifier: appGroup)
        &lt;span class="k"&gt;else&lt;/span&gt; { &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;nil&lt;/span&gt; }
        inbox = root.appending(path: &lt;span class="s"&gt;"Mailbox/Inbox"&lt;/span&gt;)
        staging = root.appending(path: &lt;span class="s"&gt;"Mailbox/Staging"&lt;/span&gt;)
        &lt;span class="k"&gt;do&lt;/span&gt; {
            &lt;span class="k"&gt;try&lt;/span&gt; files.createDirectory(
                at: inbox, withIntermediateDirectories: &lt;span class="k"&gt;true&lt;/span&gt;)
            &lt;span class="k"&gt;try&lt;/span&gt; files.createDirectory(
                at: staging, withIntermediateDirectories: &lt;span class="k"&gt;true&lt;/span&gt;)
        } &lt;span class="k"&gt;catch&lt;/span&gt; {
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;nil&lt;/span&gt;
        }
    }

    &lt;span class="c"&gt;/// Extension side: writes one message, then rings the doorbell.&lt;/span&gt;
    func send&amp;lt;Body: Codable &amp;amp; Sendable&amp;gt;(&lt;span class="k"&gt;_&lt;/span&gt; body: Body) &lt;span class="k"&gt;throws&lt;/span&gt; {
        &lt;span class="k"&gt;let&lt;/span&gt; envelope = Envelope(sent: .now, body: body)
        &lt;span class="k"&gt;let&lt;/span&gt; data = &lt;span class="k"&gt;try&lt;/span&gt; JSONEncoder().encode(envelope)
        &lt;span class="k"&gt;let&lt;/span&gt; name = UUID().uuidString + &lt;span class="s"&gt;".json"&lt;/span&gt;
        &lt;span class="c"&gt;// Write the whole file outside the inbox, then move it in. A&lt;/span&gt;
        &lt;span class="c"&gt;// move within one volume is atomic, so the app never sees half&lt;/span&gt;
        &lt;span class="c"&gt;// a file.&lt;/span&gt;
        let staged = staging.appending(path: name)
        &lt;span class="k"&gt;try&lt;/span&gt; data.write(to: staged)
        &lt;span class="k"&gt;try&lt;/span&gt; FileManager.default.moveItem(
            at: staged, to: inbox.appending(path: name))
        notify_post(Mailbox.ping)
    }

    &lt;span class="c"&gt;/// App side: hands each waiting message to `handle`, oldest first,&lt;/span&gt;
    &lt;span class="c"&gt;/// and deletes its file only after `handle` returns. A message whose&lt;/span&gt;
    &lt;span class="c"&gt;/// handling throws stays in the inbox for the next drain.&lt;/span&gt;
    func receive&amp;lt;Body: Codable &amp;amp; Sendable&amp;gt;(
        &lt;span class="k"&gt;_&lt;/span&gt; type: Body.Type, handle: (Body) &lt;span class="k"&gt;throws&lt;/span&gt; -&amp;gt; Void
    ) &lt;span class="k"&gt;throws&lt;/span&gt; {
        &lt;span class="k"&gt;let&lt;/span&gt; files = FileManager.default
        &lt;span class="k"&gt;let&lt;/span&gt; urls = &lt;span class="k"&gt;try&lt;/span&gt; files.contentsOfDirectory(
            at: inbox, includingPropertiesForKeys: &lt;span class="k"&gt;nil&lt;/span&gt;)
            .filter { $&lt;span class="n"&gt;0&lt;/span&gt;.pathExtension == &lt;span class="s"&gt;"json"&lt;/span&gt; }
        &lt;span class="c"&gt;// A file that will not decode stays where it is, for inspection.&lt;/span&gt;
        let waiting = urls.compactMap { url -&amp;gt; (URL, Envelope&amp;lt;Body&amp;gt;)? &lt;span class="k"&gt;in&lt;/span&gt;
            &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; data = &lt;span class="k"&gt;try&lt;/span&gt;? Data(contentsOf: url),
                  &lt;span class="k"&gt;let&lt;/span&gt; envelope = &lt;span class="k"&gt;try&lt;/span&gt;? JSONDecoder().decode(
                      Envelope&amp;lt;Body&amp;gt;.&lt;span class="k"&gt;self&lt;/span&gt;, from: data)
            &lt;span class="k"&gt;else&lt;/span&gt; { &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;nil&lt;/span&gt; }
            &lt;span class="k"&gt;return&lt;/span&gt; (url, envelope)
        }
        &lt;span class="k"&gt;for&lt;/span&gt; (url, envelope) &lt;span class="k"&gt;in&lt;/span&gt; waiting.sorted(by: { $&lt;span class="n"&gt;0.1&lt;/span&gt;.sent &amp;lt; $&lt;span class="n"&gt;1.1&lt;/span&gt;.sent }) {
            &lt;span class="k"&gt;try&lt;/span&gt; handle(envelope.body)
            &lt;span class="k"&gt;try&lt;/span&gt; files.removeItem(at: url)
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A message is written outside the inbox and moved in, so the app can't read half of one. Its file is deleted after the app has handled it, and not before. Nothing keeps a file open between calls, which matters more than it looks; see the section on the shared container.&lt;/p&gt;
&lt;p&gt;Sending, from any extension:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-swift"&gt;&lt;span class="c"&gt;/// What the share extension hands over.&lt;/span&gt;
struct SharedLink: Codable, Sendable {
    &lt;span class="k"&gt;let&lt;/span&gt; url: URL
    &lt;span class="k"&gt;let&lt;/span&gt; note: String
}

&lt;span class="k"&gt;func&lt;/span&gt; handOff(&lt;span class="k"&gt;_&lt;/span&gt; url: URL, note: String) &lt;span class="k"&gt;throws&lt;/span&gt; {
    &lt;span class="k"&gt;let&lt;/span&gt; mailbox = Mailbox(appGroup: &lt;span class="s"&gt;"group.com.example.app"&lt;/span&gt;)
    &lt;span class="k"&gt;try&lt;/span&gt; mailbox?.send(SharedLink(url: url, note: note))
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Reading, in the app. It drains the inbox when the doorbell rings, at launch, and whenever the scene becomes active:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-swift"&gt;&lt;span class="k"&gt;import&lt;/span&gt; Observation
&lt;span class="k"&gt;import&lt;/span&gt; SwiftUI

&lt;span class="c"&gt;/// The app's side of the mailbox.&lt;/span&gt;
&lt;span class="a"&gt;@MainActor&lt;/span&gt; &lt;span class="a"&gt;@Observable&lt;/span&gt;
final &lt;span class="k"&gt;class&lt;/span&gt; Inbox {
    &lt;span class="k"&gt;private&lt;/span&gt;(set) &lt;span class="k"&gt;var&lt;/span&gt; links: [SharedLink] = []
    &lt;span class="a"&gt;@ObservationIgnored&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; mailbox = Mailbox(appGroup: &lt;span class="s"&gt;"group.com.example.app"&lt;/span&gt;)
    &lt;span class="a"&gt;@ObservationIgnored&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; observer: DarwinObserver?

    &lt;span class="k"&gt;init&lt;/span&gt;() {
        observer = DarwinObserver(Mailbox.ping) { [weak &lt;span class="k"&gt;self&lt;/span&gt;] &lt;span class="k"&gt;in&lt;/span&gt;
            &lt;span class="k"&gt;self&lt;/span&gt;?.drain()
        }
        drain()
    }

    &lt;span class="c"&gt;/// Moves every waiting message out of the mailbox.&lt;/span&gt;
    func drain() {
        &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; mailbox &lt;span class="k"&gt;else&lt;/span&gt; { &lt;span class="k"&gt;return&lt;/span&gt; }
        &lt;span class="k"&gt;do&lt;/span&gt; {
            &lt;span class="k"&gt;try&lt;/span&gt; mailbox.receive(SharedLink.&lt;span class="k"&gt;self&lt;/span&gt;) { links.append($&lt;span class="n"&gt;0&lt;/span&gt;) }
        } &lt;span class="k"&gt;catch&lt;/span&gt; {
            &lt;span class="c"&gt;// Whatever was not handled is still in the inbox; the next&lt;/span&gt;
            &lt;span class="c"&gt;// drain picks it up.&lt;/span&gt;
        }
    }
}

&lt;span class="a"&gt;@main&lt;/span&gt;
&lt;span class="k"&gt;struct&lt;/span&gt; ExampleApp: App {
    &lt;span class="a"&gt;@Environment&lt;/span&gt;(\.scenePhase) &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; scenePhase
    &lt;span class="a"&gt;@State&lt;/span&gt; &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; inbox = Inbox()

    &lt;span class="k"&gt;var&lt;/span&gt; body: some Scene {
        WindowGroup {
            List(inbox.links, id: \.url) { Text($&lt;span class="n"&gt;0&lt;/span&gt;.note) }
        }
        .onChange(of: scenePhase) { &lt;span class="k"&gt;_&lt;/span&gt;, phase &lt;span class="k"&gt;in&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; phase == .active { inbox.drain() }
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In a UIKit app, call &lt;code&gt;drain()&lt;/code&gt; from &lt;code&gt;sceneDidBecomeActive(_:)&lt;/code&gt; instead.&lt;/p&gt;
&lt;p&gt;A race condition can make the message arrive twice. If the app is killed after handling one and before deleting its file, the next drain hands it over again, so whatever the app does with a message has to cope with seeing it a second time. The array in the example only stands in for that; a real app saves the message somewhere before the file goes.&lt;/p&gt;
&lt;h2 id="notification-service-extensions"&gt;Notification service extensions&lt;/h2&gt;
&lt;p&gt;A notification service extension runs when an alert notification with &lt;code&gt;mutable-content&lt;/code&gt; arrives, and it has &lt;a href="https://developer.apple.com/documentation/usernotifications/unnotificationserviceextension/didreceive(_:withcontenthandler:)"&gt;no more than 30 seconds&lt;/a&gt; to process the notification and take any other necessary actions. It can't launch the app or open it. The usual request is to get whatever the push brought into the app's data straight away.&lt;/p&gt;
&lt;p&gt;For that, use the mailbox. Write what the app needs, ring the doorbell, and hand the content back:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-swift"&gt;&lt;span class="k"&gt;import&lt;/span&gt; UserNotifications

&lt;span class="c"&gt;/// What the notification tells the app.&lt;/span&gt;
struct ArrivedMessage: Codable, Sendable {
    &lt;span class="k"&gt;let&lt;/span&gt; id: String
}

final &lt;span class="k"&gt;class&lt;/span&gt; NotificationService: UNNotificationServiceExtension {
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; contentHandler: ((UNNotificationContent) -&amp;gt; Void)?
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; content: UNMutableNotificationContent?

    override &lt;span class="k"&gt;func&lt;/span&gt; didReceive(
        &lt;span class="k"&gt;_&lt;/span&gt; request: UNNotificationRequest,
        withContentHandler contentHandler:
            &lt;span class="a"&gt;@escaping&lt;/span&gt; (UNNotificationContent) -&amp;gt; Void
    ) {
        &lt;span class="k"&gt;self&lt;/span&gt;.contentHandler = contentHandler
        &lt;span class="k"&gt;let&lt;/span&gt; content = request.content.mutableCopy()
            &lt;span class="k"&gt;as&lt;/span&gt;? UNMutableNotificationContent
        &lt;span class="k"&gt;self&lt;/span&gt;.content = content

        &lt;span class="c"&gt;// A note for the app. It reads it the next time it runs, or now,&lt;/span&gt;
        &lt;span class="c"&gt;// if it's running and hears the doorbell.&lt;/span&gt;
        if &lt;span class="k"&gt;let&lt;/span&gt; id = request.content.userInfo[&lt;span class="s"&gt;"id"&lt;/span&gt;] &lt;span class="k"&gt;as&lt;/span&gt;? String {
            &lt;span class="k"&gt;try&lt;/span&gt;? Mailbox(appGroup: &lt;span class="s"&gt;"group.com.example.app"&lt;/span&gt;)?
                .send(ArrivedMessage(id: id))
        }
        contentHandler(content ?? request.content)
    }

    override &lt;span class="k"&gt;func&lt;/span&gt; serviceExtensionTimeWillExpire() {
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; contentHandler, &lt;span class="k"&gt;let&lt;/span&gt; content {
            contentHandler(content)
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For data the app needs only when the person taps the notification, there's a simpler route: leave it in the notification's &lt;code&gt;userInfo&lt;/code&gt;. A tap that launches the app brings the response in the scene's &lt;a href="https://developer.apple.com/documentation/uikit/uiscene/connectionoptions/notificationresponse"&gt;connection options&lt;/a&gt; as &lt;code&gt;notificationResponse&lt;/code&gt;, and a tap while the app is running goes to &lt;code&gt;userNotificationCenter(_:didReceive:withCompletionHandler:)&lt;/code&gt;. Either way, the payload is in &lt;code&gt;response.notification.request.content.userInfo&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;What the extension can't do is get the app to run before the person opens it. If that matters, you can try to have your server send a background notification as well; see the section on pushes further down to understand the downsides of that.&lt;/p&gt;
&lt;h2 id="location-push-service-extensions"&gt;Location push service extensions&lt;/h2&gt;
&lt;p&gt;A location push service extension runs when your server sends a location push. It finds the device's location and sends it to your server, which is the job Apple designed it for. Apple's article on &lt;a href="https://developer.apple.com/documentation/corelocation/creating-a-location-push-service-extension"&gt;creating one&lt;/a&gt; lists the conditions. The app needs Always authorization, and a device gets about 360 location pushes a day, refilled at roughly one every few minutes.&lt;/p&gt;
&lt;p&gt;For the app's copy, it's the mailbox again:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-swift"&gt;&lt;span class="k"&gt;import&lt;/span&gt; CoreLocation

&lt;span class="c"&gt;/// A location for the app to pick up.&lt;/span&gt;
struct SharedLocation: Codable, Sendable {
    &lt;span class="k"&gt;let&lt;/span&gt; latitude: Double
    &lt;span class="k"&gt;let&lt;/span&gt; longitude: Double
    &lt;span class="k"&gt;let&lt;/span&gt; time: Date
}

final &lt;span class="k"&gt;class&lt;/span&gt; LocationPushService: NSObject, CLLocationPushServiceExtension,
                                 CLLocationManagerDelegate {
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; completion: (() -&amp;gt; Void)?
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; manager: CLLocationManager?

    &lt;span class="k"&gt;func&lt;/span&gt; didReceiveLocationPushPayload(
        &lt;span class="k"&gt;_&lt;/span&gt; payload: [String: &lt;span class="k"&gt;Any&lt;/span&gt;], completion: &lt;span class="a"&gt;@escaping&lt;/span&gt; () -&amp;gt; Void
    ) {
        &lt;span class="k"&gt;self&lt;/span&gt;.completion = completion
        &lt;span class="k"&gt;let&lt;/span&gt; manager = CLLocationManager()
        manager.delegate = &lt;span class="k"&gt;self&lt;/span&gt;
        manager.requestLocation()
        &lt;span class="k"&gt;self&lt;/span&gt;.manager = manager
    }

    &lt;span class="k"&gt;func&lt;/span&gt; locationManager(&lt;span class="k"&gt;_&lt;/span&gt; manager: CLLocationManager,
                         didUpdateLocations locations: [CLLocation]) {
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; location = locations.last {
            &lt;span class="c"&gt;// Send it to your server first. Then leave a copy for the app.&lt;/span&gt;
            try? Mailbox(appGroup: &lt;span class="s"&gt;"group.com.example.app"&lt;/span&gt;)?.send(
                SharedLocation(latitude: location.coordinate.latitude,
                               longitude: location.coordinate.longitude,
                               time: location.timestamp))
        }
        finish()
    }

    &lt;span class="k"&gt;func&lt;/span&gt; locationManager(&lt;span class="k"&gt;_&lt;/span&gt; manager: CLLocationManager,
                         didFailWithError error: any Error) {
        finish()
    }

    &lt;span class="k"&gt;func&lt;/span&gt; serviceExtensionWillTerminate() {
        finish()
    }

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt; finish() {
        completion?()
        completion = &lt;span class="k"&gt;nil&lt;/span&gt;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The same article tells you not to keep sending pushes for long-running or frequent location work, and to hand that work to the app instead, with Core Location's &lt;a href="https://developer.apple.com/documentation/corelocation/cllocationupdate/liveupdates(_:)"&gt;&lt;code&gt;liveUpdates(_:)&lt;/code&gt;&lt;/a&gt;. That reads like a call the extension makes. It isn't. Nothing gets handed over for you, and the extension has no way to start anything in the app.&lt;/p&gt;
&lt;p&gt;What it means is that the app runs the continuous part itself. The WWDC23 session &lt;a href="https://developer.apple.com/videos/play/wwdc2023/10180/"&gt;Discover streamlined location updates&lt;/a&gt; explains the pieces. An app receiving live updates in a &lt;code&gt;CLBackgroundActivitySession&lt;/code&gt; keeps getting them in the background, and if it's terminated, Core Location launches it in the background again when there are updates. On that launch the app has to call &lt;code&gt;liveUpdates(_:)&lt;/code&gt; again and recreate its session objects. The catch is that a background activity session can only be started while the app is in the foreground; from the background, the app can only rejoin one it started earlier. Apple's &lt;a href="https://developer.apple.com/documentation/corelocation/handling-location-updates-in-the-background"&gt;background location guide&lt;/a&gt; says the same of &lt;code&gt;CLServiceSession&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;So the app starts continuous sharing when the person turns it on, with the app on screen, and keeps it going until they turn it off:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-swift"&gt;&lt;span class="k"&gt;import&lt;/span&gt; CoreLocation

&lt;span class="c"&gt;/// Continuous sharing, run by the app itself.&lt;/span&gt;
&lt;span class="a"&gt;@MainActor&lt;/span&gt;
final &lt;span class="k"&gt;class&lt;/span&gt; LocationSharing {
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; service: CLServiceSession?
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; background: CLBackgroundActivitySession?
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; updates: Task&amp;lt;Void, Never&amp;gt;?

    &lt;span class="c"&gt;/// Call when the person turns sharing on, with the app on screen,&lt;/span&gt;
    &lt;span class="c"&gt;/// and again at every launch while sharing is on.&lt;/span&gt;
    func start() {
        service = CLServiceSession(authorization: .always)
        background = CLBackgroundActivitySession()
        updates = Task {
            &lt;span class="k"&gt;do&lt;/span&gt; {
                &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; update &lt;span class="k"&gt;in&lt;/span&gt; CLLocationUpdate.liveUpdates() {
                    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; location = update.location { report(location) }
                }
            } &lt;span class="k"&gt;catch&lt;/span&gt; {
                &lt;span class="c"&gt;// The updates ended; log it.&lt;/span&gt;
            }
        }
    }

    &lt;span class="c"&gt;/// Call when the person turns sharing off.&lt;/span&gt;
    func stop() {
        updates?.cancel()
        background?.invalidate()
        service?.invalidate()
        updates = &lt;span class="k"&gt;nil&lt;/span&gt;
        background = &lt;span class="k"&gt;nil&lt;/span&gt;
        service = &lt;span class="k"&gt;nil&lt;/span&gt;
    }

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt; report(&lt;span class="k"&gt;_&lt;/span&gt; location: CLLocation) {
        &lt;span class="c"&gt;// Send it to your server.&lt;/span&gt;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Keep a flag in the shared container that says sharing is on, and call &lt;code&gt;start()&lt;/code&gt; at launch whenever it's set. The extension then handles the occasional request while continuous sharing is off. If the extension learns that continuous sharing should start, it can only leave a note in the mailbox; the app acts on it the next time the person opens it.&lt;/p&gt;
&lt;h2 id="darwin-notifications-not-to-be-confused-by-push-notifications"&gt;Darwin notifications &lt;em&gt;Not to be confused by push notifications&lt;/em&gt;&lt;/h2&gt;
&lt;p&gt;The mailbox's doorbell is a Darwin notification: a name posted to the system's notification server, which tells every process registered for that name. The API is &lt;a href="https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/notify.3.html"&gt;&lt;code&gt;notify.h&lt;/code&gt;&lt;/a&gt;, and the two calls you need are &lt;code&gt;notify_post&lt;/code&gt; and &lt;code&gt;notify_register_dispatch&lt;/code&gt;, both used in the mailbox code above. Every process on the device shares the same names, so start yours with your App Group identifier.&lt;/p&gt;
&lt;h3 id="what-a-notification-carries"&gt;What a notification carries&lt;/h3&gt;
&lt;p&gt;Nothing. A posted notification is an event with no contents at all.&lt;/p&gt;
&lt;p&gt;The notification server does keep one 64-bit number per name, which any process can set with &lt;code&gt;notify_set_state&lt;/code&gt; and read with &lt;code&gt;notify_get_state&lt;/code&gt;. That number isn't attached to any notification; it's a variable that happens to live next to the name. Each set replaces the old value, and a process gets whatever the value is at the moment it asks. In my test below, three notifications posted with the values 1, 2 and 3 all read 3 when they were delivered. A version counter is about the most it's good for. Messages go in the container.&lt;/p&gt;
&lt;h3 id="use-notify-h-or-core-foundation"&gt;Use notify.h or Core Foundation?&lt;/h3&gt;
&lt;p&gt;Core Foundation offers the same notifications through &lt;a href="https://developer.apple.com/documentation/corefoundation/cfnotificationcentergetdarwinnotifycenter()"&gt;&lt;code&gt;CFNotificationCenterGetDarwinNotifyCenter()&lt;/code&gt;&lt;/a&gt;, which its documentation describes as a front end to &lt;code&gt;notify.h&lt;/code&gt;. Its functions are the same ones used for distributed notifications on the Mac, which is why they take an object, a &lt;code&gt;userInfo&lt;/code&gt; dictionary and a &lt;code&gt;deliverImmediately&lt;/code&gt; flag. For Darwin notifications, &lt;a href="https://developer.apple.com/documentation/corefoundation/cfnotificationcenterpostnotification(_:_:_:_:_:)"&gt;&lt;code&gt;CFNotificationCenterPostNotification&lt;/code&gt;&lt;/a&gt; ignores all three. No version of a Darwin notification carries a dictionary, or reaches a suspended app any sooner.&lt;/p&gt;
&lt;p&gt;So the choice is only about convenience. The Core Foundation version needs the main thread's run loop and a C callback with a context pointer. &lt;code&gt;notify.h&lt;/code&gt; takes a block, runs it on any queue you like, and gives you the state variable. I use &lt;code&gt;notify.h&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id="when-the-app-is-suspended"&gt;When the app is suspended&lt;/h3&gt;
&lt;p&gt;&lt;a href="https://developer.apple.com/forums/thread/769398"&gt;Apple's reply&lt;/a&gt; to a developer whose background app kept missing these: iOS doesn't resume a suspended app to deliver one. If the app is resumed later, it should get the notification then. If it's terminated while suspended, it never does.&lt;/p&gt;
&lt;p&gt;I wanted to know what "should" means in practice, so I tested it in the iOS 27 simulator. The test app logs a line every second while it runs, so the log shows exactly when it's suspended, and logs every Darwin notification it receives.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Posted while suspended&lt;/th&gt;&lt;th&gt;Then&lt;/th&gt;&lt;th&gt;Delivered&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;3 notifications, 5 s apart&lt;/td&gt;&lt;td&gt;resumed 30 s later&lt;/td&gt;&lt;td&gt;3, within a millisecond of resuming, all reading the last state value&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;1 notification&lt;/td&gt;&lt;td&gt;resumed about 12 hours later&lt;/td&gt;&lt;td&gt;1, on resuming&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;100 notifications in 14 s&lt;/td&gt;&lt;td&gt;resumed 5 s later&lt;/td&gt;&lt;td&gt;17, on resuming&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;1 notification&lt;/td&gt;&lt;td&gt;terminated, then launched&lt;/td&gt;&lt;td&gt;none&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;A notification posted to a suspended app is held for as long as the process lives. There's no documented time limit. The practical limit is the suspended process itself, which the system can terminate at any moment to free memory. And what's held isn't a queue. The &lt;a href="https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/notify.3.html"&gt;&lt;code&gt;notify(3)&lt;/code&gt; man page&lt;/a&gt; says notifications posted in quick succession may be coalesced, and that the kernel's queues for them are limited, which is how 100 posts turned into 17. One delivery tells you something was posted since you last looked. It never tells you how many times.&lt;/p&gt;
&lt;p&gt;Run tests like this from the Home Screen. Apple's reply makes the point too: under the Xcode debugger the app is never suspended, so notifications that would wait, or be lost, arrive straight away.&lt;/p&gt;
&lt;h3 id="cfmessageport"&gt;CFMessagePort&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;CFMessagePort&lt;/code&gt; gets suggested because it can carry data. Its documentation says &lt;a href="https://developer.apple.com/documentation/corefoundation/cfmessageportcreatelocal(_:_:_:_:_:)"&gt;&lt;code&gt;CFMessagePortCreateLocal&lt;/code&gt;&lt;/a&gt; returns &lt;code&gt;NULL&lt;/code&gt; on iOS 7 and later, and on the Mac, where it does work, &lt;a href="https://developer.apple.com/forums/thread/760557"&gt;Apple's advice&lt;/a&gt; is to use XPC. Leave it alone.&lt;/p&gt;
&lt;h2 id="getting-the-app-to-run"&gt;Getting the app to run&lt;/h2&gt;
&lt;p&gt;The mailbox waits for the app. These don't, but each one works only for some extensions, or only from some parts of the system. Check that yours is one of them before you plan around it.&lt;/p&gt;
&lt;h3 id="a-background-url-session"&gt;A background URL session&lt;/h3&gt;
&lt;p&gt;If the extension has a file to download or upload, it can hand the transfer to a background &lt;code&gt;URLSession&lt;/code&gt; and finish. When the transfer is done, the system wakes the containing app to deal with the result, not the extension. Apple's guide &lt;a href="https://developer.apple.com/library/archive/documentation/General/Conceptual/ExtensibilityPG/ExtensionScenarios.html"&gt;describes this&lt;/a&gt;, and a &lt;a href="https://developer.apple.com/forums/thread/76659"&gt;post by Apple DTS&lt;/a&gt; on networking in short-lived extensions fills in the details.&lt;/p&gt;
&lt;p&gt;It goes like this:&lt;/p&gt;
&lt;ol&gt;&lt;li&gt;The extension creates a background session in the App Group, starts the task, and finishes. The system carries on with the transfer.&lt;/li&gt;&lt;li&gt;When the transfer ends and no process is connected to the session, the system resumes the app, or launches it in the background, and calls &lt;a href="https://developer.apple.com/documentation/uikit/uiapplicationdelegate/application(_:handleeventsforbackgroundurlsession:completionhandler:)"&gt;&lt;code&gt;application(_:handleEventsForBackgroundURLSession:completionHandler:)&lt;/code&gt;&lt;/a&gt;.&lt;/li&gt;&lt;li&gt;The app creates a session with the same identifier, which reconnects to the transfer, and the session's delegate gets the results.&lt;/li&gt;&lt;li&gt;When the session calls &lt;code&gt;urlSessionDidFinishEvents(forBackgroundURLSession:)&lt;/code&gt;, the app invalidates the session and calls the completion handler it got in step 2, on the main thread.&lt;/li&gt;&lt;/ol&gt;
&lt;p&gt;The configuration and the delegate go in shared code:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-swift"&gt;&lt;span class="k"&gt;import&lt;/span&gt; Foundation

&lt;span class="c"&gt;/// The background session both targets use.&lt;/span&gt;
enum Transfer {
    &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; sessionID = &lt;span class="s"&gt;"com.example.app.transfer"&lt;/span&gt;
    &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; appGroup = &lt;span class="s"&gt;"group.com.example.app"&lt;/span&gt;

    &lt;span class="c"&gt;/// Creates, or reconnects to, the shared background session.&lt;/span&gt;
    static &lt;span class="k"&gt;func&lt;/span&gt; session(delegate: URLSessionDelegate) -&amp;gt; URLSession {
        &lt;span class="k"&gt;let&lt;/span&gt; configuration = URLSessionConfiguration
            .background(withIdentifier: sessionID)
        &lt;span class="c"&gt;// Required in an extension: without it the session is&lt;/span&gt;
        &lt;span class="c"&gt;// invalidated as soon as it is created.&lt;/span&gt;
        configuration.sharedContainerIdentifier = appGroup
        &lt;span class="c"&gt;// Wake or launch the app when the transfer needs attention.&lt;/span&gt;
        configuration.sessionSendsLaunchEvents = &lt;span class="k"&gt;true&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; URLSession(configuration: configuration,
                          delegate: delegate, delegateQueue: &lt;span class="k"&gt;nil&lt;/span&gt;)
    }
}

&lt;span class="c"&gt;/// A downloaded file waiting in the App Group container.&lt;/span&gt;
struct DownloadedFile: Codable, Sendable {
    &lt;span class="k"&gt;let&lt;/span&gt; name: String
}

&lt;span class="c"&gt;/// Compiled into both targets: whichever process is connected to the&lt;/span&gt;
&lt;span class="c"&gt;/// session when a task finishes gets the callback.&lt;/span&gt;
final &lt;span class="k"&gt;class&lt;/span&gt; TransferDelegate: NSObject, URLSessionDownloadDelegate {
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; finishedEvents: &lt;span class="a"&gt;@Sendable&lt;/span&gt; () -&amp;gt; Void

    &lt;span class="k"&gt;init&lt;/span&gt;(finishedEvents: &lt;span class="a"&gt;@escaping&lt;/span&gt; &lt;span class="a"&gt;@Sendable&lt;/span&gt; () -&amp;gt; Void = {}) {
        &lt;span class="k"&gt;self&lt;/span&gt;.finishedEvents = finishedEvents
    }

    &lt;span class="k"&gt;func&lt;/span&gt; urlSession(&lt;span class="k"&gt;_&lt;/span&gt; session: URLSession,
                    downloadTask: URLSessionDownloadTask,
                    didFinishDownloadingTo location: URL) {
        &lt;span class="c"&gt;// `location` is temporary: move the file before returning, into&lt;/span&gt;
        &lt;span class="c"&gt;// the shared container, and tell the app it is there.&lt;/span&gt;
        let files = FileManager.default
        &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; root = files.containerURL(
                  forSecurityApplicationGroupIdentifier: Transfer.appGroup),
              &lt;span class="k"&gt;let&lt;/span&gt; mailbox = Mailbox(appGroup: Transfer.appGroup)
        &lt;span class="k"&gt;else&lt;/span&gt; { &lt;span class="k"&gt;return&lt;/span&gt; }
        &lt;span class="k"&gt;let&lt;/span&gt; name = UUID().uuidString
        &lt;span class="k"&gt;do&lt;/span&gt; {
            &lt;span class="k"&gt;try&lt;/span&gt; files.moveItem(at: location,
                               to: root.appending(path: name))
            &lt;span class="k"&gt;try&lt;/span&gt; mailbox.send(DownloadedFile(name: name))
        } &lt;span class="k"&gt;catch&lt;/span&gt; {
            &lt;span class="c"&gt;// Log it; the download has to be retried.&lt;/span&gt;
        }
    }

    &lt;span class="k"&gt;func&lt;/span&gt; urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
        finishedEvents()
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The extension starts the download and gets out of the way:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-swift"&gt;&lt;span class="k"&gt;func&lt;/span&gt; download(&lt;span class="k"&gt;_&lt;/span&gt; url: URL) {
    &lt;span class="k"&gt;let&lt;/span&gt; session = Transfer.session(delegate: TransferDelegate())
    session.downloadTask(with: url).resume()
    &lt;span class="c"&gt;// The extension can be terminated from here on; the system finishes&lt;/span&gt;
    &lt;span class="c"&gt;// the download and hands the result to the app.&lt;/span&gt;
    extensionContext?.completeRequest(returningItems: &lt;span class="k"&gt;nil&lt;/span&gt;)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The app picks it up:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-swift"&gt;&lt;span class="k"&gt;import&lt;/span&gt; UIKit

final &lt;span class="k"&gt;class&lt;/span&gt; AppDelegate: NSObject, UIApplicationDelegate {
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; session: URLSession?
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; finishEvents: (() -&amp;gt; Void)?

    &lt;span class="k"&gt;func&lt;/span&gt; application(
        &lt;span class="k"&gt;_&lt;/span&gt; application: UIApplication,
        handleEventsForBackgroundURLSession identifier: String,
        completionHandler: &lt;span class="a"&gt;@escaping&lt;/span&gt; () -&amp;gt; Void
    ) {
        finishEvents = completionHandler
        &lt;span class="c"&gt;// Creating the session with the same identifier reconnects to&lt;/span&gt;
        &lt;span class="c"&gt;// it. Never create a second one with the same identifier in one&lt;/span&gt;
        &lt;span class="c"&gt;// process.&lt;/span&gt;
        guard session == &lt;span class="k"&gt;nil&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; { &lt;span class="k"&gt;return&lt;/span&gt; }
        session = Transfer.session(delegate: TransferDelegate { [weak &lt;span class="k"&gt;self&lt;/span&gt;] &lt;span class="k"&gt;in&lt;/span&gt;
            Task { &lt;span class="a"&gt;@MainActor&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;?.eventsDone() }
        })
    }

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt; eventsDone() {
        &lt;span class="c"&gt;// Disconnect, so the extension can use the session next time,&lt;/span&gt;
        &lt;span class="c"&gt;// then tell the system the app has handled everything.&lt;/span&gt;
        session?.finishTasksAndInvalidate()
        session = &lt;span class="k"&gt;nil&lt;/span&gt;
        finishEvents?()
        finishEvents = &lt;span class="k"&gt;nil&lt;/span&gt;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A SwiftUI app attaches this class with &lt;code&gt;@UIApplicationDelegateAdaptor&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Three things trip people up. Only one process can be connected to a background session at a time, and a second one that creates it gets &lt;code&gt;NSURLErrorBackgroundSessionInUseByAnotherProcess&lt;/code&gt;; that's why the app invalidates the session when it's done with it. A short transfer may finish while the extension is still around and complete there, a long one completes in the app, and you can't choose, so the delegate lives in both. And with Background App Refresh turned off, for the app or for the whole device, the system doesn't wake the app for the session at all. &lt;a href="https://developer.apple.com/forums/thread/124265"&gt;Apple's reply&lt;/a&gt; dates that to iOS 13. The extension can't check the setting, but the app can, and can warn the person that transfers finish only when they open it:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-swift"&gt;&lt;span class="k"&gt;let&lt;/span&gt; wakes = UIApplication.shared.backgroundRefreshStatus == .available&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id="app-intents-from-widgets-and-controls"&gt;App Intents, from widgets and controls&lt;/h3&gt;
&lt;p&gt;An app intent is a Swift type that describes one thing your app can do: a title, the parameters it needs, and a &lt;code&gt;perform()&lt;/code&gt; method that does it. You write it once, and the system can then run it for you from Shortcuts, Siri, Spotlight, the Action button, a control in Control Center, or a button in a widget or a Live Activity, without the person opening the app. The framework is &lt;a href="https://developer.apple.com/documentation/appintents/appintent"&gt;App Intents&lt;/a&gt;, which arrived in iOS 16 and has grown in every release since.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-swift"&gt;&lt;span class="k"&gt;import&lt;/span&gt; AppIntents

&lt;span class="c"&gt;/// Saves a link. The system runs `perform()` when the button is tapped.&lt;/span&gt;
struct SaveLinkIntent: AppIntent {
    &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; title: LocalizedStringResource = &lt;span class="s"&gt;"Save Link"&lt;/span&gt;

    &lt;span class="c"&gt;// iOS 27: run this in the app's process, never the widget's.&lt;/span&gt;
    static &lt;span class="k"&gt;var&lt;/span&gt; allowedExecutionTargets: IntentExecutionTargets { .main }

    &lt;span class="a"&gt;@Parameter&lt;/span&gt;(title: &lt;span class="s"&gt;"Link"&lt;/span&gt;)
    &lt;span class="k"&gt;var&lt;/span&gt; url: URL

    &lt;span class="k"&gt;init&lt;/span&gt;() {}

    &lt;span class="k"&gt;init&lt;/span&gt;(url: URL) {
        &lt;span class="k"&gt;self&lt;/span&gt;.url = url
    }

    &lt;span class="k"&gt;func&lt;/span&gt; perform() async &lt;span class="k"&gt;throws&lt;/span&gt; -&amp;gt; some IntentResult {
        &lt;span class="k"&gt;try&lt;/span&gt; Mailbox(appGroup: &lt;span class="s"&gt;"group.com.example.app"&lt;/span&gt;)?
            .send(SharedLink(url: url, note: &lt;span class="s"&gt;"From the widget"&lt;/span&gt;))
        &lt;span class="k"&gt;return&lt;/span&gt; .result()
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A widget runs it from a button:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-swift"&gt;Button(intent: SaveLinkIntent(url: url)) {
    Label(&lt;span class="s"&gt;"Save"&lt;/span&gt;, systemImage: &lt;span class="s"&gt;"bookmark"&lt;/span&gt;)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What matters for this article is which process runs &lt;code&gt;perform()&lt;/code&gt;. By default, a widget's intent runs in the widget extension. &lt;a href="https://developer.apple.com/documentation/widgetkit/adding-interactivity-to-widgets-and-live-activities"&gt;WidgetKit's documentation&lt;/a&gt; lists the cases where it runs in the app instead: intents that conform to &lt;code&gt;LiveActivityIntent&lt;/code&gt;, &lt;code&gt;AudioPlaybackIntent&lt;/code&gt;, &lt;code&gt;ForegroundContinuableIntent&lt;/code&gt; or &lt;code&gt;PushToTalkTransmissionIntent&lt;/code&gt;, and intents that set &lt;code&gt;openAppWhenRun&lt;/code&gt;. For a &lt;a href="https://developer.apple.com/documentation/appintents/liveactivityintent"&gt;&lt;code&gt;LiveActivityIntent&lt;/code&gt;&lt;/a&gt;, the system launches the app's process without bringing the app to the screen.&lt;/p&gt;
&lt;p&gt;The last two releases changed how you say where an intent runs. iOS 26 deprecated &lt;code&gt;openAppWhenRun&lt;/code&gt; in favour of &lt;a href="https://developer.apple.com/documentation/appintents/appintent/supportedmodes"&gt;&lt;code&gt;supportedModes&lt;/code&gt;&lt;/a&gt;, which says whether an intent runs in the background, in the foreground, or starts in one and moves to the other. iOS 27 added &lt;a href="https://developer.apple.com/documentation/appintents/appintent/allowedexecutiontargets"&gt;&lt;code&gt;allowedExecutionTargets&lt;/code&gt;&lt;/a&gt;, which names the processes that may run it: &lt;code&gt;.main&lt;/code&gt; for the app, &lt;code&gt;.appIntentsExtension&lt;/code&gt;, &lt;code&gt;.widgetKitExtension&lt;/code&gt;, or a combination (&lt;a href="https://developer.apple.com/documentation/appintents/intentexecutiontargets"&gt;&lt;code&gt;IntentExecutionTargets&lt;/code&gt;&lt;/a&gt;). Left alone, it's any available target. The intent above says &lt;code&gt;.main&lt;/code&gt;, so its code always runs in the app. iOS 27 also added &lt;code&gt;LongRunningIntent&lt;/code&gt;, for work that needs more than the usual 30 seconds; Apple's &lt;a href="https://developer.apple.com/documentation/updates/appintents"&gt;App Intents updates&lt;/a&gt; page has the rest. Whichever you use, the intent has to be compiled into the app target for the app to run it.&lt;/p&gt;
&lt;p&gt;This only helps where the system runs intents. A share extension, a notification service extension or a location push extension can't run an app intent to reach the app.&lt;/p&gt;
&lt;h3 id="opening-the-app"&gt;Opening the app&lt;/h3&gt;
&lt;p&gt;A widget or Live Activity can open the app when it's tapped, with &lt;a href="https://developer.apple.com/documentation/swiftui/view/widgeturl(_:)"&gt;&lt;code&gt;widgetURL(_:)&lt;/code&gt;&lt;/a&gt;, a &lt;code&gt;Link&lt;/code&gt;, or a button that runs an &lt;a href="https://developer.apple.com/documentation/appintents/openurlintent"&gt;&lt;code&gt;OpenURLIntent&lt;/code&gt;&lt;/a&gt; (iOS 18), which hands one of your universal links to the app:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-swift"&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; url = URL(string: &lt;span class="s"&gt;"https://example.com/links"&lt;/span&gt;) {
    Button(intent: OpenURLIntent(url)) {
        Label(&lt;span class="s"&gt;"Open"&lt;/span&gt;, systemImage: &lt;span class="s"&gt;"arrow.up.forward.app"&lt;/span&gt;)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A share or action extension has no supported way to do it. &lt;a href="https://developer.apple.com/documentation/foundation/nsextensioncontext/open(_:completionhandler:)"&gt;&lt;code&gt;NSExtensionContext.open(_:completionHandler:)&lt;/code&gt;&lt;/a&gt; works only where the extension point allows it, which on iOS means Today and iMessage extensions, and an iMessage extension can open only its own app. When a developer asked in 2025, &lt;a href="https://developer.apple.com/forums/thread/773342"&gt;Apple's reply&lt;/a&gt; was that there's no supported way to launch an app from an app extension, Today extensions and widgets aside.&lt;/p&gt;
&lt;p&gt;There are workarounds going around that reach &lt;code&gt;UIApplication&lt;/code&gt; from inside an extension. They aren't part of the documented API, they can break in any release, and &lt;a href="https://developer.apple.com/app-store/review/guidelines/#2.5.1"&gt;App Review guideline 2.5.1&lt;/a&gt; asks apps to use public APIs only for their intended purpose. I won't go into them here.&lt;/p&gt;
&lt;h3 id="a-push-from-your-own-server"&gt;A push from your own server&lt;/h3&gt;
&lt;p&gt;Any extension that can reach the network can ask your server to wake the app with a background notification. The request to APNs carries &lt;code&gt;apns-push-type: background&lt;/code&gt; and &lt;code&gt;apns-priority: 5&lt;/code&gt;, and the payload holds nothing but &lt;code&gt;content-available&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;{ "aps": { "content-available": 1 } }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The app treats it as one more reason to check the mailbox:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-swift"&gt;&lt;span class="k"&gt;func&lt;/span&gt; application(
    &lt;span class="k"&gt;_&lt;/span&gt; application: UIApplication,
    didReceiveRemoteNotification userInfo: [AnyHashable: &lt;span class="k"&gt;Any&lt;/span&gt;]
) async -&amp;gt; UIBackgroundFetchResult {
    &lt;span class="c"&gt;// The push is the wake-up; the message is in the mailbox.&lt;/span&gt;
    drainMailbox()
    &lt;span class="k"&gt;return&lt;/span&gt; .newData
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But don't count on it. &lt;a href="https://developer.apple.com/documentation/usernotifications/pushing-background-updates-to-your-app"&gt;Apple's documentation&lt;/a&gt; says the system treats these as low priority, doesn't guarantee delivery, throttles them, and suggests no more than two or three an hour. It launches the app if it has to, unless the person force-quit it, and gives it up to 10 seconds.&lt;/p&gt;
&lt;h2 id="network-extension-and-file-provider"&gt;Network Extension and File Provider&lt;/h2&gt;
&lt;p&gt;These two extension types are the exceptions: each has a real channel to the app.&lt;/p&gt;
&lt;h3 id="network-extension"&gt;Network Extension&lt;/h3&gt;
&lt;p&gt;The containing app sends &lt;code&gt;Data&lt;/code&gt; to its tunnel provider with &lt;a href="https://developer.apple.com/documentation/networkextension/netunnelprovidersession/sendprovidermessage(_:responsehandler:)"&gt;&lt;code&gt;sendProviderMessage(_:responseHandler:)&lt;/code&gt;&lt;/a&gt;, and the provider answers in &lt;a href="https://developer.apple.com/documentation/networkextension/netunnelprovider/handleappmessage(_:completionhandler:)"&gt;&lt;code&gt;handleAppMessage(_:completionHandler:)&lt;/code&gt;&lt;/a&gt;. Only the app can start an exchange. The documentation warns that the app may not be running when the provider is, since the provider can be started from Settings or by Connect On Demand, so don't use this channel to start or stop the tunnel.&lt;/p&gt;
&lt;p&gt;When the provider has news for the app, &lt;a href="https://developer.apple.com/forums/thread/705530"&gt;Apple's reply&lt;/a&gt; suggests long polling. The app asks for the status, the provider holds on to the completion handler and calls it at the next change, and the app asks again:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-swift"&gt;&lt;span class="c"&gt;// App side.&lt;/span&gt;
&lt;span class="a"&gt;@MainActor&lt;/span&gt;
final &lt;span class="k"&gt;class&lt;/span&gt; TunnelStatus {
    &lt;span class="k"&gt;func&lt;/span&gt; watch(&lt;span class="k"&gt;_&lt;/span&gt; session: NETunnelProviderSession) {
        &lt;span class="k"&gt;do&lt;/span&gt; {
            &lt;span class="k"&gt;try&lt;/span&gt; session.sendProviderMessage(Data(&lt;span class="s"&gt;"status"&lt;/span&gt;.utf8)) { reply &lt;span class="k"&gt;in&lt;/span&gt;
                Task { &lt;span class="a"&gt;@MainActor&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
                    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; reply { &lt;span class="k"&gt;self&lt;/span&gt;.show(reply) }
                    &lt;span class="k"&gt;self&lt;/span&gt;.watch(session)   &lt;span class="c"&gt;// ask again: long polling&lt;/span&gt;
                }
            }
        } &lt;span class="k"&gt;catch&lt;/span&gt; {
            &lt;span class="c"&gt;// The provider is not running; observe NEVPNStatusDidChange.&lt;/span&gt;
        }
    }

    &lt;span class="k"&gt;func&lt;/span&gt; show(&lt;span class="k"&gt;_&lt;/span&gt; data: Data) {}
}

&lt;span class="c"&gt;// Provider side.&lt;/span&gt;
final &lt;span class="k"&gt;class&lt;/span&gt; PacketTunnelProvider: NEPacketTunnelProvider {
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; waiting: (&lt;span class="a"&gt;@Sendable&lt;/span&gt; (Data?) -&amp;gt; Void)?

    override &lt;span class="k"&gt;func&lt;/span&gt; handleAppMessage(
        &lt;span class="k"&gt;_&lt;/span&gt; messageData: Data,
        completionHandler: (&lt;span class="a"&gt;@Sendable&lt;/span&gt; (Data?) -&amp;gt; Void)? = &lt;span class="k"&gt;nil&lt;/span&gt;
    ) {
        &lt;span class="c"&gt;// Keep the request open; answer it at the next change.&lt;/span&gt;
        waiting = completionHandler
    }

    &lt;span class="k"&gt;func&lt;/span&gt; statusChanged(to status: Data) {
        &lt;span class="c"&gt;// A completion handler can be called only once.&lt;/span&gt;
        waiting?(status)
        waiting = &lt;span class="k"&gt;nil&lt;/span&gt;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If connected and disconnected are all you need, observe &lt;code&gt;NEVPNStatusDidChange&lt;/code&gt; and skip the messaging.&lt;/p&gt;
&lt;h3 id="file-provider"&gt;File Provider&lt;/h3&gt;
&lt;p&gt;&lt;a href="https://developer.apple.com/documentation/foundation/nsfileproviderservice"&gt;&lt;code&gt;NSFileProviderService&lt;/code&gt;&lt;/a&gt; gives the app and a File Provider extension an &lt;code&gt;NSXPCConnection&lt;/code&gt;, with an &lt;code&gt;@objc&lt;/code&gt; protocol both sides compile. The extension offers services through &lt;code&gt;NSFileProviderServiceSource&lt;/code&gt;, and the app asks for them by item:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-swift"&gt;&lt;span class="a"&gt;@objc&lt;/span&gt; &lt;span class="k"&gt;protocol&lt;/span&gt; AccountService {
    &lt;span class="k"&gt;func&lt;/span&gt; signOut(reply: &lt;span class="a"&gt;@escaping&lt;/span&gt; (Error?) -&amp;gt; Void)
}

&lt;span class="k"&gt;func&lt;/span&gt; accountService(&lt;span class="k"&gt;for&lt;/span&gt; item: URL) async &lt;span class="k"&gt;throws&lt;/span&gt; -&amp;gt; AccountService? {
    &lt;span class="k"&gt;let&lt;/span&gt; services = &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; FileManager.default
        .fileProviderServicesForItem(at: item)
    &lt;span class="k"&gt;let&lt;/span&gt; name = NSFileProviderServiceName(&lt;span class="s"&gt;"com.example.account"&lt;/span&gt;)
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; service = services[name] &lt;span class="k"&gt;else&lt;/span&gt; { &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;nil&lt;/span&gt; }
    &lt;span class="k"&gt;let&lt;/span&gt; connection = &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; service.fileProviderConnection()
    connection.remoteObjectInterface = NSXPCInterface(with: AccountService.&lt;span class="k"&gt;self&lt;/span&gt;)
    connection.resume()
    &lt;span class="k"&gt;return&lt;/span&gt; connection.remoteObjectProxy &lt;span class="k"&gt;as&lt;/span&gt;? AccountService
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;a href="https://developer.apple.com/documentation/foundation/filemanager/getfileproviderservicesforitem(at:completionhandler:)"&gt;documentation&lt;/a&gt; says a service you get this way works only on the item at that URL. For something that isn't about one item, like signing out, &lt;a href="https://developer.apple.com/forums/thread/711923"&gt;one developer reports&lt;/a&gt; that the root container works (&lt;a href="https://developer.apple.com/documentation/fileprovider/nsfileprovideritemidentifier/rootcontainer"&gt;&lt;code&gt;NSFileProviderItemIdentifier.rootContainer&lt;/code&gt;&lt;/a&gt;). Apple hasn't said whether that's intended. You should treat anything that is not documented as a bug that will eventually be fixed.&lt;/p&gt;
&lt;h2 id="the-shared-container"&gt;The shared container&lt;/h2&gt;
&lt;p&gt;Everything else goes through the App Group container: &lt;a href="https://developer.apple.com/documentation/foundation/userdefaults/init(suitename:)"&gt;&lt;code&gt;UserDefaults(suiteName:)&lt;/code&gt;&lt;/a&gt; for small values, files under &lt;a href="https://developer.apple.com/documentation/foundation/filemanager/containerurl(forsecurityapplicationgroupidentifier:)"&gt;&lt;code&gt;containerURL(forSecurityApplicationGroupIdentifier:)&lt;/code&gt;&lt;/a&gt;, a Core Data, SwiftData or SQLite store kept there, and a shared keychain access group for secrets.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-swift"&gt;UserDefaults(suiteName: &lt;span class="s"&gt;"group.com.example.app"&lt;/span&gt;)?
    .set(Date.now, forKey: &lt;span class="s"&gt;"lastShare"&lt;/span&gt;)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two processes are now using the same files, and Apple's guide &lt;a href="https://developer.apple.com/library/archive/documentation/General/Conceptual/ExtensibilityPG/ExtensionScenarios.html"&gt;asks you to coordinate&lt;/a&gt; with Core Data, SQLite or POSIX locks. File coordination works as well, on one condition: an extension that uses &lt;code&gt;NSFilePresenter&lt;/code&gt; has to remove its presenters when it goes into the background.&lt;/p&gt;
&lt;p&gt;Locks are also how the shared container gets processes killed. A process suspended while holding a file lock or an SQLite database lock is terminated with the code &lt;code&gt;0xdead10cc&lt;/code&gt;. Apple's &lt;a href="https://developer.apple.com/documentation/xcode/sigkill"&gt;crash report documentation&lt;/a&gt; gives the fix: ask for time to finish the work and release the lock before suspension. A database open in both the app and an extension, either of which can be suspended at any moment, is where this usually comes from. Wrap each write:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-swift"&gt;&lt;span class="c"&gt;// In the app.&lt;/span&gt;
let task = UIApplication.shared.beginBackgroundTask(withName: &lt;span class="s"&gt;"Save"&lt;/span&gt;)
save()
UIApplication.shared.endBackgroundTask(task)

&lt;span class="c"&gt;// In an extension, which can't use UIApplication.&lt;/span&gt;
ProcessInfo.processInfo.performExpiringActivity(withReason: &lt;span class="s"&gt;"Save"&lt;/span&gt;) { expiring &lt;span class="k"&gt;in&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; !expiring &lt;span class="k"&gt;else&lt;/span&gt; { &lt;span class="k"&gt;return&lt;/span&gt; }
    save()
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The mailbox above stays clear of all this. It never holds a file open, and the only process that deletes messages is the app.&lt;/p&gt;
&lt;h2 id="which-mechanism-for-which-job"&gt;Which mechanism for which job&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Mechanism&lt;/th&gt;&lt;th&gt;Works from&lt;/th&gt;&lt;th&gt;Carries data&lt;/th&gt;&lt;th&gt;App suspended&lt;/th&gt;&lt;th&gt;App not running&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Mailbox in the shared container&lt;/td&gt;&lt;td&gt;any extension&lt;/td&gt;&lt;td&gt;yes&lt;/td&gt;&lt;td&gt;read when resumed&lt;/td&gt;&lt;td&gt;read at next launch&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Darwin notification&lt;/td&gt;&lt;td&gt;any extension&lt;/td&gt;&lt;td&gt;no; a shared 64-bit value beside it&lt;/td&gt;&lt;td&gt;held until resumed, coalesced&lt;/td&gt;&lt;td&gt;lost&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Background &lt;code&gt;URLSession&lt;/code&gt;&lt;/td&gt;&lt;td&gt;extensions that transfer files&lt;/td&gt;&lt;td&gt;the transferred file&lt;/td&gt;&lt;td&gt;app resumed&lt;/td&gt;&lt;td&gt;app launched, unless Background App Refresh is off&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Background push from your server&lt;/td&gt;&lt;td&gt;any extension that can reach the network&lt;/td&gt;&lt;td&gt;the push payload&lt;/td&gt;&lt;td&gt;app resumed&lt;/td&gt;&lt;td&gt;app launched, unless force-quit; throttled&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;App intent run in the app&lt;/td&gt;&lt;td&gt;widgets, Live Activities, controls&lt;/td&gt;&lt;td&gt;the intent's parameters&lt;/td&gt;&lt;td&gt;runs&lt;/td&gt;&lt;td&gt;app process launched&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;widgetURL&lt;/code&gt;, &lt;code&gt;Link&lt;/code&gt;, &lt;code&gt;OpenURLIntent&lt;/code&gt;&lt;/td&gt;&lt;td&gt;widgets, Live Activities&lt;/td&gt;&lt;td&gt;a URL&lt;/td&gt;&lt;td&gt;app opened&lt;/td&gt;&lt;td&gt;app opened&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Provider messages&lt;/td&gt;&lt;td&gt;Network Extension&lt;/td&gt;&lt;td&gt;&lt;code&gt;Data&lt;/code&gt;, request and reply&lt;/td&gt;&lt;td colspan="2"&gt;the app starts every exchange&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;File Provider service&lt;/td&gt;&lt;td&gt;File Provider&lt;/td&gt;&lt;td&gt;XPC calls&lt;/td&gt;&lt;td colspan="2"&gt;the app starts every connection&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 id="sources"&gt;Sources&lt;/h2&gt;
&lt;p&gt;Apple documentation:&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;App Extension Programming Guide: &lt;a href="https://developer.apple.com/library/archive/documentation/General/Conceptual/ExtensibilityPG/ExtensionOverview.html"&gt;Understand How an App Extension Works&lt;/a&gt;, &lt;a href="https://developer.apple.com/library/archive/documentation/General/Conceptual/ExtensibilityPG/ExtensionCreation.html"&gt;Creating an App Extension&lt;/a&gt;, &lt;a href="https://developer.apple.com/library/archive/documentation/General/Conceptual/ExtensibilityPG/ExtensionScenarios.html"&gt;Handling Common Scenarios&lt;/a&gt;. Archived, and still the documentation for these points.&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/usernotifications/unnotificationserviceextension"&gt;&lt;code&gt;UNNotificationServiceExtension&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://developer.apple.com/documentation/usernotifications/unnotificationserviceextension/didreceive(_:withcontenthandler:)"&gt;&lt;code&gt;didReceive(_:withContentHandler:)&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://developer.apple.com/documentation/uikit/uiscene/connectionoptions/notificationresponse"&gt;&lt;code&gt;notificationResponse&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/corelocation/creating-a-location-push-service-extension"&gt;Creating a location push service extension&lt;/a&gt;, &lt;a href="https://developer.apple.com/documentation/corelocation/cllocationupdate/liveupdates(_:)"&gt;&lt;code&gt;liveUpdates(_:)&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://developer.apple.com/documentation/corelocation/handling-location-updates-in-the-background"&gt;Handling location updates in the background&lt;/a&gt;, &lt;a href="https://developer.apple.com/videos/play/wwdc2023/10180/"&gt;Discover streamlined location updates&lt;/a&gt;, WWDC23&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/backgroundtasks/bgprocessingtaskrequest"&gt;&lt;code&gt;BGProcessingTaskRequest&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/notify.3.html"&gt;&lt;code&gt;notify(3)&lt;/code&gt; man page&lt;/a&gt;, &lt;a href="https://developer.apple.com/documentation/corefoundation/cfnotificationcentergetdarwinnotifycenter()"&gt;&lt;code&gt;CFNotificationCenterGetDarwinNotifyCenter()&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://developer.apple.com/documentation/corefoundation/cfnotificationcenterpostnotification(_:_:_:_:_:)"&gt;&lt;code&gt;CFNotificationCenterPostNotification&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://developer.apple.com/documentation/corefoundation/cfmessageportcreatelocal(_:_:_:_:_:)"&gt;&lt;code&gt;CFMessagePortCreateLocal&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/uikit/uiapplicationdelegate/application(_:handleeventsforbackgroundurlsession:completionhandler:)"&gt;&lt;code&gt;application(_:handleEventsForBackgroundURLSession:completionHandler:)&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://developer.apple.com/documentation/foundation/urlsessionconfiguration/sharedcontaineridentifier"&gt;&lt;code&gt;sharedContainerIdentifier&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://developer.apple.com/documentation/uikit/uiapplication/backgroundrefreshstatus"&gt;&lt;code&gt;backgroundRefreshStatus&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/appintents/appintent"&gt;&lt;code&gt;AppIntent&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://developer.apple.com/documentation/appintents/appintent/allowedexecutiontargets"&gt;&lt;code&gt;allowedExecutionTargets&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://developer.apple.com/documentation/appintents/intentexecutiontargets"&gt;&lt;code&gt;IntentExecutionTargets&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://developer.apple.com/documentation/appintents/appintent/supportedmodes"&gt;&lt;code&gt;supportedModes&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://developer.apple.com/documentation/appintents/liveactivityintent"&gt;&lt;code&gt;LiveActivityIntent&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://developer.apple.com/documentation/appintents/openurlintent"&gt;&lt;code&gt;OpenURLIntent&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://developer.apple.com/documentation/updates/appintents"&gt;App Intents updates&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/widgetkit/adding-interactivity-to-widgets-and-live-activities"&gt;Adding interactivity to widgets and Live Activities&lt;/a&gt;, &lt;a href="https://developer.apple.com/documentation/swiftui/view/widgeturl(_:)"&gt;&lt;code&gt;widgetURL(_:)&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/foundation/nsextensioncontext/open(_:completionhandler:)"&gt;&lt;code&gt;NSExtensionContext.open(_:completionHandler:)&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://developer.apple.com/app-store/review/guidelines/#2.5.1"&gt;App Review Guidelines, 2.5.1&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/usernotifications/pushing-background-updates-to-your-app"&gt;Pushing background updates to your app&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/networkextension/netunnelprovidersession/sendprovidermessage(_:responsehandler:)"&gt;&lt;code&gt;sendProviderMessage(_:responseHandler:)&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://developer.apple.com/documentation/networkextension/netunnelprovider/handleappmessage(_:completionhandler:)"&gt;&lt;code&gt;handleAppMessage(_:completionHandler:)&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/foundation/nsfileproviderservice"&gt;&lt;code&gt;NSFileProviderService&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://developer.apple.com/documentation/foundation/filemanager/getfileproviderservicesforitem(at:completionhandler:)"&gt;&lt;code&gt;getFileProviderServicesForItem(at:completionHandler:)&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://developer.apple.com/documentation/fileprovider/nsfileprovideritemidentifier/rootcontainer"&gt;&lt;code&gt;rootContainer&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/foundation/userdefaults/init(suitename:)"&gt;&lt;code&gt;UserDefaults(suiteName:)&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://developer.apple.com/documentation/foundation/filemanager/containerurl(forsecurityapplicationgroupidentifier:)"&gt;&lt;code&gt;containerURL(forSecurityApplicationGroupIdentifier:)&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://developer.apple.com/documentation/xcode/sigkill"&gt;EXC_CRASH (SIGKILL)&lt;/a&gt;&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;Apple Developer Forums:&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/forums/thread/769398"&gt;Darwin notifications and suspended apps&lt;/a&gt;, Apple's reply&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/forums/thread/760557"&gt;&lt;code&gt;CFMessagePort&lt;/code&gt; and XPC&lt;/a&gt;, Apple's reply&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/forums/thread/76659"&gt;Networking in a short-lived extension&lt;/a&gt;, a post by Apple DTS&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/forums/thread/124265"&gt;Background sessions and Background App Refresh&lt;/a&gt;, Apple's reply&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/forums/thread/773342"&gt;Opening an app from a share extension&lt;/a&gt;, Apple's reply&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/forums/thread/705530"&gt;Messaging between a VPN app and its provider&lt;/a&gt;, Apple's reply&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/forums/thread/711923"&gt;A File Provider service without an item&lt;/a&gt;, a developer's report&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;Other:&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;&lt;a href="https://github.com/griddynamics-archive/MMWormhole"&gt;MMWormhole&lt;/a&gt;, archived&lt;/li&gt;&lt;/ul&gt;</content:encoded></item><item><title>What happened to launchOptions</title><link>https://7strikes.dev/notes/launch-options/</link><guid isPermaLink="true">https://7strikes.dev/notes/launch-options/</guid><pubDate>Thu, 15 Oct 2026 00:00:00 +0000</pubDate><description>In an app that supports scenes, launchOptions is nil, and as of the iOS 27 SDK every app has to support scenes. Where each of the 14 launch options went, and the 14 connection options that took their place.</description><content:encoded>&lt;p&gt;&lt;code&gt;application(_:didFinishLaunchingWithOptions:)&lt;/code&gt; still has a &lt;code&gt;launchOptions&lt;/code&gt; parameter, and in a scene-based app it is always &lt;code&gt;nil&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Apple’s documentation for the parameter says so in one line: "If the app supports scenes, this is &lt;code&gt;nil&lt;/code&gt;."&lt;/p&gt;
&lt;p&gt;Code that checks it for the reason the app was launched finds nothing in an app with a scene manifest.&lt;/p&gt;
&lt;p&gt;Until now that was a choice. The iOS 27 release notes make it the rule: "Apps built with the latest SDK must adopt the scene-based life cycle or they fail to launch."&lt;/p&gt;
&lt;p&gt;All 14 &lt;code&gt;UIApplication.LaunchOptionsKey&lt;/code&gt; values are deprecated, twelve of them in iOS 26, and each key's documentation page names what to use instead.&lt;/p&gt;
&lt;h2 id="the-14-launch-options"&gt;The 14 launch options&lt;/h2&gt;
&lt;p&gt;Six moved to &lt;code&gt;UIScene.ConnectionOptions&lt;/code&gt;, which UIKit passes to &lt;code&gt;scene(_:willConnectTo:options:)&lt;/code&gt; when it creates a scene, and &lt;code&gt;eventAttribution&lt;/code&gt; now travels with the URL it belongs to.&lt;/p&gt;
&lt;p&gt;Five described launches for background work. For those there is nothing in the connection options; the replacement is the object that asked for the event, recreated at every launch.&lt;/p&gt;
&lt;p&gt;Two are no longer used.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Launch option&lt;/th&gt;&lt;th&gt;Deprecated&lt;/th&gt;&lt;th&gt;Use instead&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;url&lt;/code&gt;&lt;/td&gt;&lt;td&gt;iOS 26&lt;/td&gt;&lt;td&gt;&lt;code&gt;urlContexts&lt;/code&gt; in the connection options&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;sourceApplication&lt;/code&gt;&lt;/td&gt;&lt;td&gt;iOS 26&lt;/td&gt;&lt;td&gt;&lt;code&gt;sourceApplication&lt;/code&gt; in the connection options&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;shortcutItem&lt;/code&gt;&lt;/td&gt;&lt;td&gt;iOS 26&lt;/td&gt;&lt;td&gt;&lt;code&gt;shortcutItem&lt;/code&gt; in the connection options&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;userActivityDictionary&lt;/code&gt;&lt;/td&gt;&lt;td&gt;iOS 26&lt;/td&gt;&lt;td&gt;&lt;code&gt;userActivities&lt;/code&gt; in the connection options&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;userActivityType&lt;/code&gt;&lt;/td&gt;&lt;td&gt;iOS 26&lt;/td&gt;&lt;td&gt;&lt;code&gt;handoffUserActivityType&lt;/code&gt; in the connection options&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;cloudKitShareMetadata&lt;/code&gt;&lt;/td&gt;&lt;td&gt;iOS 26&lt;/td&gt;&lt;td&gt;&lt;code&gt;cloudKitShareMetadata&lt;/code&gt; in the connection options&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;eventAttribution&lt;/code&gt;&lt;/td&gt;&lt;td&gt;iOS 26&lt;/td&gt;&lt;td&gt;&lt;code&gt;eventAttribution&lt;/code&gt; in the &lt;code&gt;options&lt;/code&gt; of each URL context&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;bluetoothCentrals&lt;/code&gt;&lt;/td&gt;&lt;td&gt;iOS 26&lt;/td&gt;&lt;td&gt;Create the &lt;code&gt;CBCentralManager&lt;/code&gt; at launch with the same &lt;code&gt;CBCentralManagerOptionRestoreIdentifierKey&lt;/code&gt;. The state arrives in &lt;code&gt;centralManager(_:willRestoreState:)&lt;/code&gt;.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;bluetoothPeripherals&lt;/code&gt;&lt;/td&gt;&lt;td&gt;iOS 26&lt;/td&gt;&lt;td&gt;The same for &lt;code&gt;CBPeripheralManager&lt;/code&gt;, with &lt;code&gt;CBPeripheralManagerOptionRestoreIdentifierKey&lt;/code&gt; and &lt;code&gt;peripheralManager(_:willRestoreState:)&lt;/code&gt;.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;location&lt;/code&gt;&lt;/td&gt;&lt;td&gt;iOS 26&lt;/td&gt;&lt;td&gt;Restart &lt;code&gt;CLLocationUpdate&lt;/code&gt; or &lt;code&gt;CLMonitor&lt;/code&gt;, or recreate the &lt;code&gt;CLLocationManager&lt;/code&gt; and its delegate. Location data was never in the launch options; it always went to the delegate.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;remoteNotification&lt;/code&gt;&lt;/td&gt;&lt;td&gt;iOS 26&lt;/td&gt;&lt;td&gt;&lt;code&gt;application(_:didReceiveRemoteNotification:fetchCompletionHandler:)&lt;/code&gt; for a silent notification. A notification the person tapped arrives as &lt;code&gt;notificationResponse&lt;/code&gt; in the connection options.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;newsstandDownloads&lt;/code&gt;&lt;/td&gt;&lt;td&gt;iOS 26&lt;/td&gt;&lt;td&gt;Nothing. No longer used.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;annotation&lt;/code&gt;&lt;/td&gt;&lt;td&gt;iOS 16&lt;/td&gt;&lt;td&gt;Nothing. No longer used.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;localNotification&lt;/code&gt;&lt;/td&gt;&lt;td&gt;iOS 10&lt;/td&gt;&lt;td&gt;&lt;code&gt;userNotificationCenter(_:didReceive:withCompletionHandler:)&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 id="the-14-connection-options"&gt;The 14 connection options&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;UIScene.ConnectionOptions&lt;/code&gt; carries the reason UIKit created the scene. Seven of its properties date from iOS 13, when scenes arrived. Six belong to other frameworks whose events can create a scene. The last, &lt;code&gt;sceneAccessoryUserInfo&lt;/code&gt;, is new in UIKit in iOS 27.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Connection option&lt;/th&gt;&lt;th&gt;Framework, since&lt;/th&gt;&lt;th&gt;Why the scene was created&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;urlContexts&lt;/code&gt;&lt;/td&gt;&lt;td&gt;UIKit, iOS 13&lt;/td&gt;&lt;td&gt;URLs to open, each with its options&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;sourceApplication&lt;/code&gt;&lt;/td&gt;&lt;td&gt;UIKit, iOS 13&lt;/td&gt;&lt;td&gt;the bundle ID of the app that made the request&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;shortcutItem&lt;/code&gt;&lt;/td&gt;&lt;td&gt;UIKit, iOS 13&lt;/td&gt;&lt;td&gt;a Home Screen quick action&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;userActivities&lt;/code&gt;&lt;/td&gt;&lt;td&gt;UIKit, iOS 13&lt;/td&gt;&lt;td&gt;user activities to configure the scene's interface from&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;handoffUserActivityType&lt;/code&gt;&lt;/td&gt;&lt;td&gt;UIKit, iOS 13&lt;/td&gt;&lt;td&gt;the type of a pending Handoff activity&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;cloudKitShareMetadata&lt;/code&gt;&lt;/td&gt;&lt;td&gt;UIKit, iOS 13&lt;/td&gt;&lt;td&gt;an accepted CloudKit share&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;notificationResponse&lt;/code&gt;&lt;/td&gt;&lt;td&gt;UIKit, iOS 13&lt;/td&gt;&lt;td&gt;a person's response to a notification&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;nfcEvent&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Core NFC, iOS 17.4&lt;/td&gt;&lt;td&gt;an NFC reader's field, or a gesture to start a contactless transaction&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;credentialSessionEvent&lt;/code&gt;&lt;/td&gt;&lt;td&gt;SecureElementCredential, iOS 17.4&lt;/td&gt;&lt;td&gt;a credential session event&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;marketplaceDisplayOption&lt;/code&gt;&lt;/td&gt;&lt;td&gt;MarketplaceKit, iOS 17.4&lt;/td&gt;&lt;td&gt;a deep link the system makes into an alternative app marketplace&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;gameControllerActivationContext&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Game Controller, iOS 18&lt;/td&gt;&lt;td&gt;a game controller event; carries the previously active app's bundle ID&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;appIntent&lt;/code&gt;&lt;/td&gt;&lt;td&gt;App Intents, iOS 26&lt;/td&gt;&lt;td&gt;an app intent that opens the app; &lt;code&gt;scene(_:willPerform:)&lt;/code&gt; follows&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;shouldHandleActiveWorkoutRecovery&lt;/code&gt;&lt;/td&gt;&lt;td&gt;HealthKit UI, iOS 26&lt;/td&gt;&lt;td&gt;a workout session to recover&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;sceneAccessoryUserInfo&lt;/code&gt;&lt;/td&gt;&lt;td&gt;UIKit, iOS 27&lt;/td&gt;&lt;td&gt;the user info given when the scene accessory was created&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote class="callout important"&gt;&lt;p&gt;&lt;strong class="label"&gt;Important:&lt;/strong&gt; The six framework options are declared by their own frameworks, not by UIKit, and importing UIKit does not bring them in. Import the framework in the file where the scene delegate reads them. If nothing in the module imports it, the property does not exist and the compiler reports "value of type 'UIScene.ConnectionOptions' has no member 'nfcEvent'". With Swift's member import visibility turned on (SE-0444), an import in another file is not enough either: the import has to be in the same file, and the error names the missing module.&lt;/p&gt;&lt;/blockquote&gt;
&lt;pre&gt;&lt;code class="language-swift"&gt;&lt;span class="k"&gt;import&lt;/span&gt; UIKit
&lt;span class="k"&gt;import&lt;/span&gt; AppIntents               &lt;span class="c"&gt;// appIntent&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; CoreNFC                  &lt;span class="c"&gt;// nfcEvent&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; GameController           &lt;span class="c"&gt;// gameControllerActivationContext&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; HealthKitUI              &lt;span class="c"&gt;// shouldHandleActiveWorkoutRecovery&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; MarketplaceKit           &lt;span class="c"&gt;// marketplaceDisplayOption&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; SecureElementCredential  &lt;span class="c"&gt;// credentialSessionEvent&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Import only the ones whose events your app handles.&lt;/p&gt;
&lt;p&gt;Apple's pages for &lt;code&gt;marketplaceDisplayOption&lt;/code&gt;, &lt;code&gt;gameControllerActivationContext&lt;/code&gt; and &lt;code&gt;shouldHandleActiveWorkoutRecovery&lt;/code&gt; have no description; those rows come from the types they return and the SDK headers. The same pages list the last two under UIKit, but they are declared in Game Controller and HealthKit UI.&lt;/p&gt;
&lt;p&gt;The connection options describe the launch that created the scene. When the scene already exists, UIKit calls a scene delegate method instead: &lt;code&gt;scene(_:openURLContexts:)&lt;/code&gt; for URLs, &lt;code&gt;scene(_:continue:)&lt;/code&gt; for user activities, &lt;code&gt;windowScene(_:performActionFor:completionHandler:)&lt;/code&gt; for quick actions and &lt;code&gt;windowScene(_:userDidAcceptCloudKitShareWith:)&lt;/code&gt; for shares.&lt;/p&gt;
&lt;p&gt;A scene delegate that covers the common cases:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-swift"&gt;&lt;span class="k"&gt;func&lt;/span&gt; scene(&lt;span class="k"&gt;_&lt;/span&gt; scene: UIScene, willConnectTo session: UISceneSession,
           options connectionOptions: UIScene.ConnectionOptions) {
    &lt;span class="c"&gt;// was .url&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; context = connectionOptions.urlContexts.first {
        &lt;span class="k"&gt;open&lt;/span&gt;(context.url)
    }
    &lt;span class="c"&gt;// was .shortcutItem&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; item = connectionOptions.shortcutItem {
        perform(item)
    }
    &lt;span class="c"&gt;// was .userActivityDictionary&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; activity = connectionOptions.userActivities.first {
        resume(activity)
    }
    &lt;span class="c"&gt;// a notification the person tapped&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; response = connectionOptions.notificationResponse {
        show(response)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id="the-launches-with-no-connection-option"&gt;The launches with no connection option&lt;/h2&gt;
&lt;p&gt;Since there is no launch reason to test, the app delegate sets up whatever receives background events every time, whatever the reason for the launch:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-swift"&gt;&lt;span class="k"&gt;func&lt;/span&gt; application(
    &lt;span class="k"&gt;_&lt;/span&gt; application: UIApplication,
    didFinishLaunchingWithOptions
        launchOptions: [UIApplication.LaunchOptionsKey: &lt;span class="k"&gt;Any&lt;/span&gt;]?
) -&amp;gt; Bool {
    &lt;span class="c"&gt;// launchOptions is nil in a scene-based app. Set up whatever&lt;/span&gt;
    &lt;span class="c"&gt;// receives background events on every launch.&lt;/span&gt;
    UNUserNotificationCenter.current().delegate = notificationHandler
    &lt;span class="k"&gt;let&lt;/span&gt; key = CBCentralManagerOptionRestoreIdentifierKey
    central = CBCentralManager(delegate: bluetooth, queue: &lt;span class="k"&gt;nil&lt;/span&gt;,
                               options: [key: &lt;span class="s"&gt;"central"&lt;/span&gt;])
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The notification center's delegate belongs in the app delegate too. Its documentation asks for it to be set in &lt;code&gt;application(_:willFinishLaunchingWithOptions:)&lt;/code&gt; or &lt;code&gt;application(_:didFinishLaunchingWithOptions:)&lt;/code&gt;, so that the notification that launched the app reaches it.&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/uikit/uiapplication/launchoptionskey"&gt;UIApplication.LaunchOptionsKey&lt;/a&gt;, with the replacement on each key's page&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/uikit/uiscene/connectionoptions"&gt;UIScene.ConnectionOptions&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/uikit/transitioning-to-the-uikit-scene-based-life-cycle"&gt;Transitioning to the UIKit scene-based life cycle&lt;/a&gt;&lt;/li&gt;&lt;/ul&gt;</content:encoded></item><item><title>Core Location has a maritime activity type, as of iOS 27</title><link>https://7strikes.dev/notes/maritime-activity-type/</link><guid isPermaLink="true">https://7strikes.dev/notes/maritime-activity-type/</guid><pubDate>Mon, 05 Oct 2026 00:00:00 +0000</pubDate><description>It arrived without a WWDC session or a release note. If your app is used on the water, it is the activity type to set.</description><content:encoded>&lt;p&gt;iOS 27 added &lt;code&gt;.maritime&lt;/code&gt; to &lt;code&gt;CLActivityType&lt;/code&gt;, and a matching &lt;code&gt;.maritime&lt;/code&gt; to &lt;code&gt;CLLocationUpdate.LiveConfiguration&lt;/code&gt;. There was no WWDC session about it and the iOS 27 release notes do not mention it. It appears in the SDK headers and on two documentation pages, so most of the developers who need it have not seen it.&lt;/p&gt;
&lt;p&gt;Apple's documentation describes it as positioning for "activities in vessels on water, including while anchored", with sailing, motor-boating and traveling by ship as the examples. Both are available from version 27 on every platform.&lt;/p&gt;
&lt;p&gt;Until now the documented choice for a boat was &lt;code&gt;.otherNavigation&lt;/code&gt;, whose description still lists boats alongside cycling, scooters, trains and off-road vehicles. An app used on the water should set &lt;code&gt;.maritime&lt;/code&gt; on iOS 27 and later, and keep &lt;code&gt;.otherNavigation&lt;/code&gt; for earlier systems.&lt;/p&gt;
&lt;p&gt;With &lt;code&gt;CLLocationManager&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-swift"&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;#available&lt;/span&gt;(iOS &lt;span class="n"&gt;27&lt;/span&gt;, macOS &lt;span class="n"&gt;27&lt;/span&gt;, watchOS &lt;span class="n"&gt;27&lt;/span&gt;, visionOS &lt;span class="n"&gt;27&lt;/span&gt;, *) {
    manager.activityType = .maritime
} &lt;span class="k"&gt;else&lt;/span&gt; {
    manager.activityType = .otherNavigation
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With live updates:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-swift"&gt;&lt;span class="k"&gt;let&lt;/span&gt; configuration: CLLocationUpdate.LiveConfiguration
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;#available&lt;/span&gt;(iOS &lt;span class="n"&gt;27&lt;/span&gt;, macOS &lt;span class="n"&gt;27&lt;/span&gt;, watchOS &lt;span class="n"&gt;27&lt;/span&gt;, visionOS &lt;span class="n"&gt;27&lt;/span&gt;, *) {
    configuration = .maritime
} &lt;span class="k"&gt;else&lt;/span&gt; {
    configuration = .otherNavigation
}
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; update &lt;span class="k"&gt;in&lt;/span&gt; CLLocationUpdate.liveUpdates(configuration) {
    &lt;span class="c"&gt;// ...&lt;/span&gt;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If location on the water has given you trouble, &lt;code&gt;.maritime&lt;/code&gt; is the first thing to try. It is a one-line change, and the SDK header &lt;code&gt;CLLocationUpdater.h&lt;/code&gt; describes it as "tuned for vehicular maritime use cases". Before you file a bug or write a filter for odd positions on the water, switch to it and look again.&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/corelocation/clactivitytype/maritime"&gt;CLActivityType.maritime&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/corelocation/cllocationupdate/liveconfiguration"&gt;CLLocationUpdate.LiveConfiguration&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/corelocation/cllocationmanager/activitytype"&gt;activityType&lt;/a&gt;&lt;/li&gt;&lt;/ul&gt;</content:encoded></item><item><title>A SensorKit fetch that returns nothing is usually asking for the wrong window</title><link>https://7strikes.dev/articles/sensorkit-fetch-returns-nothing/</link><guid isPermaLink="true">https://7strikes.dev/articles/sensorkit-fetch-returns-nothing/</guid><pubDate>Tue, 22 Sep 2026 00:00:00 +0000</pubDate><category>SensorKit</category><description>A fetch cannot reach earlier than your app's authorization or later than 24 hours ago, and a range that breaks either rule returns no samples and no error.</description><content:encoded>&lt;p&gt;The fetch completes. &lt;code&gt;sensorReader(_:didCompleteFetch:)&lt;/code&gt; is called. &lt;code&gt;sensorReader(_:fetching:didFetchResult:)&lt;/code&gt; never is, and &lt;code&gt;sensorReader(_:fetching:failedWithError:)&lt;/code&gt; never is either. There is no error to look up, because as far as the framework is concerned the request succeeded and matched nothing.&lt;/p&gt;
&lt;p&gt;Almost always the request asked for a window that cannot contain data. The window has an edge at each end.&lt;/p&gt;
&lt;h2 id="the-recent-edge-the-24-hour-hold"&gt;The recent edge: the 24-hour hold&lt;/h2&gt;
&lt;p&gt;SensorKit keeps newly recorded data to itself for 24 hours before any app can read it, so the user has a chance to delete what they would rather not share. That much is documented on &lt;code&gt;SRFetchRequest&lt;/code&gt;, along with the part that surprises people:&lt;/p&gt;
&lt;blockquote class="callout important"&gt;&lt;p&gt;&lt;strong class="label"&gt;Important:&lt;/strong&gt; A fetch request returns no results at all if its time range overlaps the holding period. The range is not trimmed to the part that has aged past the hold. One hour of overlap and the whole request comes back empty.&lt;/p&gt;&lt;/blockquote&gt;
&lt;p&gt;So &lt;code&gt;to: Date()&lt;/code&gt;, the obvious thing to write and the thing every first implementation writes, can never return a sample. Neither can "the last hour", or "today". The most recent moment worth asking for is 24 hours ago, and asking for anything newer costs the entire result, not the tail of it.&lt;/p&gt;
&lt;h2 id="the-far-edge-before-your-app-was-authorized"&gt;The far edge: before your app was authorized&lt;/h2&gt;
&lt;p&gt;An app cannot fetch anything from before the moment the user authorized it. That is the floor, it is per app, and nothing moves it.&lt;/p&gt;
&lt;p&gt;This is the edge the documentation makes easy to misread. &lt;code&gt;SRSensorReader&lt;/code&gt; says an app "has access to 7 days of prior recorded data for an active sensor", which reads like a promise that a device already recording for another app or for the system will hand you a week of history as soon as you are authorized. It will not. The sensor may have been running for months and the samples may be sitting on the device, but your window opens at your own authorization and not one second earlier. A &lt;code&gt;from&lt;/code&gt; behind that point does not return a truncated result. It returns nothing.&lt;/p&gt;
&lt;p&gt;Design the onboarding for that. An app that asks for a week of history on the day the user consents has nothing to show them, and the emptiness looks identical to a bug.&lt;/p&gt;
&lt;p&gt;The same floor is what makes a development cycle painful. Deleting the app and reinstalling resets its authorization, and therefore resets the floor; installing a new build over the old one does not. A workflow built on delete-and-reinstall starts every test run at the bottom of a fresh 24-hour hole, usually with a &lt;code&gt;from&lt;/code&gt; that predates the new authorization and a &lt;code&gt;to&lt;/code&gt; inside the hold. Both edges are wrong at once, and there is no error either way.&lt;/p&gt;
&lt;h2 id="writing-a-request-that-can-succeed"&gt;Writing a request that can succeed&lt;/h2&gt;
&lt;ul&gt;&lt;li&gt;Record the moment your app was authorized and persist it. Clamp every &lt;code&gt;from&lt;/code&gt; to it, rather than passing a date from the study protocol or a date picker.&lt;/li&gt;&lt;li&gt;Put &lt;code&gt;to&lt;/code&gt; at least 24 hours in the past. Treat "now" as unavailable, not as a boundary to approach.&lt;/li&gt;&lt;li&gt;Advance &lt;code&gt;from&lt;/code&gt; using the timestamp of the newest sample you have actually received, not the time of your last fetch. The gap between those two is data you will never ask for again.&lt;/li&gt;&lt;li&gt;During development, install over the previous build. Reserve delete-and-reinstall for the times you mean to start the clock again, and expect to wait a day when you do.&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;Neither edge has a switch. Both are privacy guarantees to the user. The hold gives them time to delete, and the floor means consent applies from the moment it is given, not retroactively. There is no entitlement or debug setting that shortens either one. The testing schedule has to absorb them, and so does anything you promise a study participant on their first day.&lt;/p&gt;</content:encoded></item><item><title>The default contactless app is woken by every reader</title><link>https://7strikes.dev/notes/default-contactless-app/</link><guid isPermaLink="true">https://7strikes.dev/notes/default-contactless-app/</guid><pubDate>Tue, 22 Sep 2026 00:00:00 +0000</pubDate><description>Being the default NFC contactless app means any reader field wakes your app, not only the readers asking for the identifiers you declared. There is no filter, and there can only be one default.</description><content:encoded>&lt;p&gt;An app holding &lt;code&gt;com.apple.developer.nfc.hce.default-contactless-app&lt;/code&gt; and selected as the default app receives &lt;code&gt;NFCWindowSceneEvent&lt;/code&gt;s when the phone meets a reader. The events apps expect are the ones for the cards they support. The events they get also include the access reader in the office lobby, the transit gate and the hotel room door.&lt;/p&gt;
&lt;p&gt;There is no way to narrow this, and the shape of the event says why. &lt;code&gt;readerDetected&lt;/code&gt; reports the presence of a reader. It is a field-level signal, raised before any application identifier has been exchanged, and at that instant nothing on the device knows which AID the reader is about to ask for. The prefixes declared in &lt;code&gt;com.apple.developer.nfc.hce.iso7816.select-identifier-prefixes&lt;/code&gt; govern what your &lt;code&gt;CardSession&lt;/code&gt; can answer once you are emulating a card. They do not govern whether you are woken.&lt;/p&gt;
&lt;p&gt;Also, there can only be one default. The entitlement lets a person choose your app in Settings, and that choice is singular: whatever they pick is the front door for every contactless interaction on the device. So the unsupported cases are not edge cases, they are most cases, and they need designing: come up, make it obvious you are not the right credential, and get out of the way without the user having to dismiss anything.&lt;/p&gt;
&lt;p&gt;Which invites a question worth asking before applying for the entitlement. If your app services limited identifiers, being the default means appearing at every reader its user walks past and being useless at nearly all of them. Being the default is a commitment to the whole surface, not a claim on the part of it you care about. An app that does one thing well is often better off letting the user invoke it, and leaving the front door to something general.&lt;/p&gt;</content:encoded></item><item><title>There is probably a logging profile for whatever you are stuck on</title><link>https://7strikes.dev/notes/logging-profiles/</link><guid isPermaLink="true">https://7strikes.dev/notes/logging-profiles/</guid><pubDate>Tue, 22 Sep 2026 00:00:00 +0000</pubDate><description>Default log level hides the framework's own reasoning. Apple publishes a configuration profile per subsystem that turns it on, and it has to be installed before you reproduce the problem.</description><content:encoded>&lt;p&gt;Your logs show what your code did. They rarely show what the system decided, which is usually the part you need: why the scan discovered nothing, why the silent push never woke the app, why the region event arrived four minutes after the boundary. At the default log level most of that reasoning is never written down, so you are left inferring it from the outside.&lt;/p&gt;
&lt;p&gt;Apple publishes a configuration profile for each of those subsystems on the &lt;a href="https://developer.apple.com/feedback-assistant/profiles-and-logs/"&gt;Profiles and Logs&lt;/a&gt; page. Installing one raises the log level for that subsystem on the device, and its internal decisions then appear in Console and in any sysdiagnose you capture.&lt;/p&gt;
&lt;p&gt;The list runs to well over a hundred entries and is easy to walk past. The ones that matter for the problems this site covers include Bluetooth, with a separate profile for each of iOS, macOS, watchOS, tvOS and visionOS; Location Services for iOS, macOS and watchOS; APNs; CoreNFC and SEC Frameworks; Accessory Setup Kit; Nearby Interaction; HealthKit; Background Networking; CFNetwork; and Network Diagnostics. Further down there are profiles for Battery Life, Memory Exception and HangTracer for slow UI, which are worth knowing about before you need them.&lt;/p&gt;
&lt;p&gt;Two things decide whether this is useful or wasted effort.&lt;/p&gt;
&lt;p&gt;Install the profile before you reproduce the problem. A sysdiagnose contains the log buffer as it was actually written, so a profile installed afterwards tells you nothing about what already happened. This is the most common reason a carefully collected sysdiagnose turns out to be worthless, and noticing costs you another full reproduction cycle.&lt;/p&gt;
&lt;p&gt;Remove the profile when you are finished. They are verbose by design, and there is no reason to keep one running on a device you are no longer investigating.&lt;/p&gt;
&lt;p&gt;One practical detail: the page used to live at &lt;code&gt;developer.apple.com/bug-reporting/profiles-and-logs/&lt;/code&gt;, which still redirects. Older forum threads and support mail point at that address, so a dead-looking link is usually just the old one.&lt;/p&gt;</content:encoded></item><item><title>Why Core Bluetooth state restoration never fires</title><link>https://7strikes.dev/articles/core-bluetooth-state-restoration/</link><guid isPermaLink="true">https://7strikes.dev/articles/core-bluetooth-state-restoration/</guid><pubDate>Mon, 21 Sep 2026 00:00:00 +0000</pubDate><category>Core Bluetooth</category><description>Setting a restoration identifier is only the first step. What else your app has to do, when the system will and will not relaunch it, and how to test it without fooling yourself.</description><content:encoded>&lt;h2 id="is-bluetooth-state-restoration-broken"&gt;Is Bluetooth State Restoration broken?&lt;/h2&gt;
&lt;p&gt;You follow the documentation, you set &lt;code&gt;CBCentralManagerOptionRestoreIdentifierKey&lt;/code&gt;, the system terminates your app in the background, the accessory sends something, and the app does not come back. The conclusion most people reach at that point is that state restoration is broken. But it almost never is.&lt;/p&gt;
&lt;p&gt;State restoration has strict rules, and if any one of them is not met, the app will not be relaunched. Apple covers them in the Core Bluetooth Programming Guide, in technote TN3115, and in the API reference for the restoration keys and delegate methods. This article puts them together in one place.&lt;/p&gt;
&lt;h2 id="how-restoration-works"&gt;How restoration works&lt;/h2&gt;
&lt;p&gt;An app running in the background is sooner or later going to be terminated by the system, to free up memory for something else. If the app's Core Bluetooth managers opted in to state restoration, the system holds on to their state when that happens: the scans in progress, the pending connections, the connected peripherals and the characteristics the app subscribed to. When a Bluetooth event arrives that the app was waiting for, the system launches the app in the background, hands that state back, and delivers the event. The &lt;a href="https://developer.apple.com/library/archive/documentation/NetworkingInternetWeb/Conceptual/CoreBluetooth_concepts/CoreBluetoothBackgroundProcessingForIOSApps/PerformingTasksWhileYourAppIsInTheBackground.html"&gt;Core Bluetooth Programming Guide&lt;/a&gt; describes the mechanism, and gives an app woken up this way about 10 seconds to deal with the event.&lt;/p&gt;
&lt;p&gt;So restoration is a relaunch for a specific Bluetooth event. If the app was not waiting for any event when it was terminated, the system has no reason to bring it back.&lt;/p&gt;
&lt;h2 id="what-your-app-has-to-do"&gt;What your app has to do&lt;/h2&gt;
&lt;p&gt;There are four requirements on the app side, and you need all of them.&lt;/p&gt;
&lt;h3 id="1-declare-the-background-mode"&gt;1. Declare the background mode&lt;/h3&gt;
&lt;p&gt;Add &lt;code&gt;bluetooth-central&lt;/code&gt; to &lt;code&gt;UIBackgroundModes&lt;/code&gt; if the app is a central, &lt;code&gt;bluetooth-peripheral&lt;/code&gt; if it is a peripheral, or both if it is both:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-xml"&gt;&amp;lt;key&amp;gt;UIBackgroundModes&amp;lt;/key&amp;gt;
&amp;lt;array&amp;gt;
    &amp;lt;string&amp;gt;bluetooth-central&amp;lt;/string&amp;gt;
&amp;lt;/array&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id="2-use-the-same-restoration-identifier-on-every-launch"&gt;2. Use the same restoration identifier on every launch&lt;/h3&gt;
&lt;p&gt;The &lt;a href="https://developer.apple.com/documentation/corebluetooth/cbcentralmanageroptionrestoreidentifierkey"&gt;documentation for the key&lt;/a&gt; says the identifier has to be identical across executions of the app, because that is how the system matches the state it saved to the manager you create. If you generate a UUID at launch, or build the string from anything that can change, the system has nothing to match it to.&lt;/p&gt;
&lt;h3 id="3-create-the-manager-at-every-launch"&gt;3. Create the manager at every launch&lt;/h3&gt;
&lt;p&gt;The programming guide was written before scenes, and its example looks in &lt;code&gt;launchOptions&lt;/code&gt; for &lt;code&gt;UIApplicationLaunchOptionsBluetoothCentralsKey&lt;/code&gt; to find out which managers to recreate. In a scene-based app &lt;code&gt;launchOptions&lt;/code&gt; is always &lt;code&gt;nil&lt;/code&gt;, and with the iOS 27 SDK every app is scene-based. The key's documentation describes the current way: persist the identifier yourself, and create the manager with it on every launch, whatever the launch was for. I have a &lt;a href="https://7strikes.dev/notes/launch-options/"&gt;note on launchOptions&lt;/a&gt; that covers the rest of the keys that went the same way.&lt;/p&gt;
&lt;h3 id="4-implement-centralmanager-willrestorestate"&gt;4. Implement centralManager(_:willRestoreState:)&lt;/h3&gt;
&lt;p&gt;When the system relaunches the app for restoration, this is the &lt;a href="https://developer.apple.com/documentation/corebluetooth/cbcentralmanagerdelegate/centralmanager(_:willrestorestate:)"&gt;first CoreBluetooth delegate method it calls&lt;/a&gt;, before &lt;code&gt;centralManagerDidUpdateState(_:)&lt;/code&gt;. The dictionary it passes holds the &lt;a href="https://developer.apple.com/documentation/corebluetooth/cbcentralmanagerrestoredstateperipheralskey"&gt;peripherals&lt;/a&gt; that were connected or had a connection pending, and the services the manager was scanning for. You will want to keep those peripherals and set yourself as their delegate again. If you do not, their events have nowhere to go.&lt;/p&gt;
&lt;blockquote class="callout important"&gt;&lt;p&gt;&lt;strong class="label"&gt;Important:&lt;/strong&gt; sometimes the system will know to relaunch your app but the parameters passed to &lt;code&gt;willRestoreState&lt;/code&gt; may be empty. In that case you will need to restore the state of your app/centrals/peripherals yourself, or start discovery from scratch&lt;/p&gt;&lt;/blockquote&gt;
&lt;p&gt;Here is a central that covers all four:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-swift"&gt;&lt;span class="k"&gt;import&lt;/span&gt; CoreBluetooth

&lt;span class="c"&gt;/// Owns the central manager. Created at every launch, whatever the reason&lt;/span&gt;
&lt;span class="c"&gt;/// for the launch, so a restoration has something to restore into.&lt;/span&gt;
final &lt;span class="k"&gt;class&lt;/span&gt; Bluetooth: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
    &lt;span class="c"&gt;/// The same string on every launch, or restoration finds nothing.&lt;/span&gt;
    static &lt;span class="k"&gt;let&lt;/span&gt; restoreID = &lt;span class="s"&gt;"com.example.app.central"&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; central: CBCentralManager?
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; known: [UUID: CBPeripheral] = [:]

    override &lt;span class="k"&gt;init&lt;/span&gt;() {
        &lt;span class="k"&gt;super&lt;/span&gt;.&lt;span class="k"&gt;init&lt;/span&gt;()
        central = CBCentralManager(
            delegate: &lt;span class="k"&gt;self&lt;/span&gt;, queue: &lt;span class="k"&gt;nil&lt;/span&gt;,
            options: [CBCentralManagerOptionRestoreIdentifierKey: Bluetooth.restoreID])
    }

    &lt;span class="k"&gt;func&lt;/span&gt; centralManager(&lt;span class="k"&gt;_&lt;/span&gt; central: CBCentralManager,
                        willRestoreState state: [String: &lt;span class="k"&gt;Any&lt;/span&gt;]) {
        &lt;span class="c"&gt;// On a relaunch, this comes first. Take the peripherals back, and&lt;/span&gt;
        &lt;span class="c"&gt;// be their delegate again, or their events go nowhere.&lt;/span&gt;
        let restored = state[CBCentralManagerRestoredStatePeripheralsKey]
            &lt;span class="k"&gt;as&lt;/span&gt;? [CBPeripheral] ?? []
        &lt;span class="k"&gt;for&lt;/span&gt; peripheral &lt;span class="k"&gt;in&lt;/span&gt; restored {
            peripheral.delegate = &lt;span class="k"&gt;self&lt;/span&gt;
            known[peripheral.identifier] = peripheral
        }
    }

    &lt;span class="k"&gt;func&lt;/span&gt; centralManagerDidUpdateState(&lt;span class="k"&gt;_&lt;/span&gt; central: CBCentralManager) {
        &lt;span class="k"&gt;guard&lt;/span&gt; central.state == .poweredOn &lt;span class="k"&gt;else&lt;/span&gt; { &lt;span class="k"&gt;return&lt;/span&gt; }
        &lt;span class="c"&gt;// Pick up where the app left off. A pending connect is what gets&lt;/span&gt;
        &lt;span class="c"&gt;// the app relaunched next time.&lt;/span&gt;
        for peripheral &lt;span class="k"&gt;in&lt;/span&gt; known.values &lt;span class="k"&gt;where&lt;/span&gt; peripheral.state == .disconnected {
            central.connect(peripheral)
        }
    }

    &lt;span class="k"&gt;func&lt;/span&gt; centralManager(&lt;span class="k"&gt;_&lt;/span&gt; central: CBCentralManager,
                        didDisconnectPeripheral peripheral: CBPeripheral,
                        error: (any Error)?) {
        &lt;span class="c"&gt;// Ask again straight away. The request stays pending while the app&lt;/span&gt;
        &lt;span class="c"&gt;// is suspended or terminated, and the connection relaunches it.&lt;/span&gt;
        central.connect(peripheral)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And the app delegate, which creates it on every launch:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-swift"&gt;&lt;span class="k"&gt;import&lt;/span&gt; UIKit

final &lt;span class="k"&gt;class&lt;/span&gt; AppDelegate: NSObject, UIApplicationDelegate {
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; bluetooth: Bluetooth?

    &lt;span class="k"&gt;func&lt;/span&gt; application(
        &lt;span class="k"&gt;_&lt;/span&gt; application: UIApplication,
        didFinishLaunchingWithOptions
            launchOptions: [UIApplication.LaunchOptionsKey: &lt;span class="k"&gt;Any&lt;/span&gt;]?
    ) -&amp;gt; Bool {
        &lt;span class="c"&gt;// Every launch. In a scene-based app launchOptions is nil, so&lt;/span&gt;
        &lt;span class="c"&gt;// there's no Bluetooth key to check, and nothing to check it for.&lt;/span&gt;
        bluetooth = Bluetooth()
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id="when-the-system-relaunches-the-app"&gt;When the system relaunches the app&lt;/h2&gt;
&lt;p&gt;Apple's &lt;a href="https://developer.apple.com/documentation/technotes/tn3115-bluetooth-state-restoration-app-relaunch-rules"&gt;TN3115&lt;/a&gt; spells out the first condition. The app is relaunched if, and only if, it was waiting for a specific Bluetooth event when it was terminated, and that event then happened. For a central that means scanning, a pending connection, or a characteristic the app subscribed to. For a peripheral it means advertising. An app that was connected to an accessory and doing nothing else is relaunched when the accessory disconnects, or when it sends a notification on a characteristic the app subscribed to.&lt;/p&gt;
&lt;p&gt;The second condition is how the app stopped, and what the person has done since. TN3115 has the full list, including what happens after a restart. A simpler way to remember it is to ask who stopped the app. When the system terminates it, or it crashes, it comes back. When the person stops it, or stops Bluetooth, it may not. And if the app was only suspended, it is resumed rather than relaunched, so &lt;code&gt;willRestoreState&lt;/code&gt; is not called at all.&lt;/p&gt;
&lt;p&gt;The case that catches most people is the force-quit, because swiping the app away in the app switcher is also how most people try to test this. Once the person force-quits the app, the system does not relaunch it for Bluetooth events until they open it again.&lt;/p&gt;
&lt;p&gt;Turning Bluetooth off is the other one, and it helps to know that there are two ways to do it. Turning it off in Settings, or with an Airplane Mode that includes Bluetooth, powers the radio off, and no app is relaunched after that, whatever it was waiting for. The Bluetooth button in Control Center does something else: Apple's &lt;a href="https://support.apple.com/en-us/102412"&gt;support article&lt;/a&gt; describes it as disconnecting accessories while Bluetooth stays on.&lt;/p&gt;
&lt;p&gt;Starting with iOS 26, apps that set up their accessories with &lt;a href="https://developer.apple.com/documentation/accessorysetupkit"&gt;AccessorySetupKit&lt;/a&gt; are relaunched after a force-quit and after the Control Center button, and other apps are not. Powering the radio off still ends restoration for everyone. If you are building an accessory app and have not looked at AccessorySetupKit yet, you should.&lt;/p&gt;
&lt;h2 id="before-you-blame-restoration"&gt;Before you blame restoration&lt;/h2&gt;
&lt;p&gt;Make sure the app was actually terminated. From the accessory's side, an app that is suspended, or still running but stuck somewhere it does not accept a connection, looks the same as one that was terminated and never came back. Log something in &lt;code&gt;application(_:didFinishLaunchingWithOptions:)&lt;/code&gt; and in &lt;code&gt;centralManager(_:willRestoreState:)&lt;/code&gt;. If neither shows up after the event, the app was never relaunched, and the next thing to check is whether it was ever terminated in the first place.&lt;/p&gt;
&lt;p&gt;If your app is the peripheral, keep in mind what happens to advertising in the background. The programming guide says the local name is not advertised, the service UUIDs go into an overflow area that only an iOS device explicitly scanning for them can see, and advertising may slow down when every advertising app is in the background. An accessory that looks for your app's local name will not find it while the app is in the background, whether it was terminated or not.&lt;/p&gt;
&lt;h2 id="testing-it"&gt;Testing it&lt;/h2&gt;
&lt;p&gt;Do not test this from Xcode. Under the debugger the app is not suspended or terminated the way it is in normal use, so you end up testing something that never happens on a customer's phone. Install the build, launch it from the Home Screen, and do not swipe it away, because that is the force-quit case above.&lt;/p&gt;
&lt;p&gt;To terminate the app when you want to, give your debug build a button that kills the process. TN3115 says a crashed app is relaunched, and this is as close to a crash as you can get on purpose:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-swift"&gt;&lt;span class="k"&gt;#if&lt;/span&gt; DEBUG
&lt;span class="c"&gt;/// Debug builds only: end the process the way a crash does, not the way&lt;/span&gt;
&lt;span class="c"&gt;/// a swipe in the app switcher does.&lt;/span&gt;
func terminateForRestorationTest() {
    kill(getpid(), SIGKILL)
}
&lt;span class="k"&gt;#endif&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote class="callout important"&gt;&lt;p&gt;&lt;strong class="label"&gt;Important:&lt;/strong&gt; it is very easy to make a mistake a ship the kill code in production. So take utmost care that this code branch only exists in your debug builds.&lt;/p&gt;&lt;/blockquote&gt;
&lt;p&gt;Get a connection pending, press the button, then have the accessory do the thing the app is waiting for. If the app does not come back, install the &lt;a href="https://developer.apple.com/feedback-assistant/profiles-and-logs/"&gt;Bluetooth logging profile&lt;/a&gt; before you reproduce it again, and take a sysdiagnose right after. The profile has to be installed before the problem happens, and I have a &lt;a href="https://7strikes.dev/notes/logging-profiles/"&gt;note on why&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="if-the-person-force-quits-the-app"&gt;If the person force-quits the app&lt;/h2&gt;
&lt;p&gt;On iOS 26 and later, AccessorySetupKit is the way to stay relaunchable. Without it, the most an app can do is let the person know. Schedule a local notification a few minutes ahead, and move it further ahead every time the app gets to run. As long as the app keeps running, the notification never fires. If the app is gone, the notification fires and asks the person to open the app again. It is a commonly used workaround.&lt;/p&gt;
&lt;h2 id="sources"&gt;Sources&lt;/h2&gt;
&lt;ul&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/technotes/tn3115-bluetooth-state-restoration-app-relaunch-rules"&gt;TN3115: Bluetooth State Restoration app relaunch rules&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/library/archive/documentation/NetworkingInternetWeb/Conceptual/CoreBluetooth_concepts/CoreBluetoothBackgroundProcessingForIOSApps/PerformingTasksWhileYourAppIsInTheBackground.html"&gt;Core Bluetooth Programming Guide: Core Bluetooth Background Processing for iOS Apps&lt;/a&gt;, archived&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/corebluetooth/cbcentralmanageroptionrestoreidentifierkey"&gt;&lt;code&gt;CBCentralManagerOptionRestoreIdentifierKey&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://developer.apple.com/documentation/corebluetooth/cbcentralmanagerdelegate/centralmanager(_:willrestorestate:)"&gt;&lt;code&gt;centralManager(_:willRestoreState:)&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://developer.apple.com/documentation/corebluetooth/cbcentralmanagerrestoredstateperipheralskey"&gt;&lt;code&gt;CBCentralManagerRestoredStatePeripheralsKey&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/accessorysetupkit"&gt;AccessorySetupKit&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://developer.apple.com/feedback-assistant/profiles-and-logs/"&gt;Profiles and Logs&lt;/a&gt;&lt;/li&gt;&lt;/ul&gt;</content:encoded></item><item><title>Why an NFC reader session has a time limit</title><link>https://7strikes.dev/articles/nfc-reader-session-time-limit/</link><guid isPermaLink="true">https://7strikes.dev/articles/nfc-reader-session-time-limit/</guid><pubDate>Mon, 21 Sep 2026 00:00:00 +0000</pubDate><category>NFC and contactless</category><description>There is no continuous NFC mode on iOS. A reader session is a short, user-visible burst with a fixed lifetime, and every way of pretending otherwise makes the app worse.</description><content:encoded>&lt;p&gt;A recurring request: keep Core NFC listening. The user walks through a building tapping tags, and the app should pick each one up without a sheet appearing and disappearing every time. Is there a session configuration for that, or a way to restart the session so quickly that nobody notices?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;There is not&lt;/strong&gt;. The time limits are hard, they are documented, and the design that tries to hide them is a worse experience than the design that accepts them. Also, abusing the NFC subsystem on an iPhone will shorten the session time due to thermal stress.&lt;/p&gt;
&lt;h2 id="what-is-fixed"&gt;What is fixed&lt;/h2&gt;
&lt;p&gt;The public &lt;code&gt;CoreNFC&lt;/code&gt; headers state the rules plainly.&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;A reader session has a &lt;strong&gt;time limit&lt;/strong&gt; from the moment &lt;code&gt;init()&lt;/code&gt; is called. When it expires, the delegate receives &lt;code&gt;readerSessionInvalidationErrorSessionTimeout&lt;/code&gt;.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;One reader session&lt;/strong&gt; can be active in the system at a time. A second &lt;code&gt;init()&lt;/code&gt; while one is active fails with &lt;code&gt;readerSessionInvalidationErrorSystemIsBusy&lt;/code&gt;.&lt;/li&gt;&lt;li&gt;Calling &lt;code&gt;init()&lt;/code&gt; presents a &lt;strong&gt;modal system sheet&lt;/strong&gt;. The sheet is dismissed when the session is invalidated, by your code, by the timeout, or by the user tapping Done, which produces &lt;code&gt;readerSessionInvalidationErrorUserCanceled&lt;/code&gt;.&lt;/li&gt;&lt;li&gt;If the app leaves the foreground, the session ends with &lt;code&gt;readerSessionInvalidationErrorSessionTerminatedUnexpectedly&lt;/code&gt;.&lt;/li&gt;&lt;li&gt;An invalidated session cannot be reused. &lt;code&gt;restartPolling()&lt;/code&gt; on it does nothing. A new session means a new &lt;code&gt;init()&lt;/code&gt;, and a new sheet.&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;None of these has a knob. There is no entitlement that extends the timeout, no configuration that suppresses the sheet, and no background mode for reader sessions.&lt;/p&gt;
&lt;h2 id="why-it-is-built-this-way"&gt;Why it is built this way&lt;/h2&gt;
&lt;p&gt;Reader mode is not simply listening. It is transmitting. For as long as a session is active, the phone is driving its antenna to generate the RF field that powers whatever tag comes into range, as the tag has no battery and takes its energy from the phone. The energy that goes into that field is drawn from the battery, and what the field does not deliver to a tag ends up as heat in the antenna and the circuits around it.&lt;/p&gt;
&lt;p&gt;That is a fine thing to do for the few seconds it takes to hold a phone against a tag. It is not a fine thing to do for the twenty minutes it takes to walk through a building. So the session is shaped like the interaction it exists for: short, started by the user, visible on screen while it runs, and over quickly. The time ceiling, the single active session, and the sheet are not three separate restrictions. They are one decision, that a reader session is a burst and not a mode.&lt;/p&gt;
&lt;blockquote class="callout important"&gt;&lt;p&gt;&lt;strong class="label"&gt;Important:&lt;/strong&gt; Restarting a session the instant it ends does not produce a “mode" either. Trying to use it as a trick will only shorten the time for the next session until the stress on the NFC subsystem will prevent the next session from starting altogether. And for a VoiceOver user, this gets worse as a new modal is announced every time. The app is now fighting the system on the user's screen, and the user can see it.&lt;/p&gt;&lt;/blockquote&gt;
&lt;h2 id="what-one-session-can-do"&gt;What one session can do&lt;/h2&gt;
&lt;p&gt;A single session can do more than one read. If the use is a short guided sequence, a couple of tags within a short time, it fits inside one session without any tricks.&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;Create the NDEF session with &lt;code&gt;invalidateAfterFirstRead: false&lt;/code&gt;. It stays open after a successful read, until the timeout, &lt;code&gt;invalidate()&lt;/code&gt;, or Done.&lt;/li&gt;&lt;li&gt;After handling a tag, call &lt;code&gt;restartPolling()&lt;/code&gt; to discover the next one. Tag objects from before the restart are invalid; drop your references.&lt;/li&gt;&lt;li&gt;Update &lt;code&gt;alertMessage&lt;/code&gt; after each read. The sheet's text can change while the session is valid, so it can say "Tag 2 of 5 read. Hold the phone near the next one."&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;When the session times out, tell the user it is over and let them start the next one. Do not start it for them.&lt;/p&gt;
&lt;h2 id="what-to-build-instead"&gt;What to build instead&lt;/h2&gt;
&lt;p&gt;Match the tool to the interaction.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;One tag, one tap.&lt;/strong&gt; Start a session on a deliberate user action, a button or a gesture, read the tag, invalidate with a message, done. This is what Core NFC is for. It is also the predictable shape for VoiceOver: one action, one sheet, one result.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;No app involvement at all.&lt;/strong&gt; Late model iPhones can read NDEF tags in the background without any reader session. Encode a universal link on the tag; when the user holds the phone near it, the system shows a notification, and tapping it delivers the tag's payload to your app. There are conditions, all &lt;a href="https://developer.apple.com/documentation/corenfc/adding-support-for-background-tag-reading"&gt;documented&lt;/a&gt;: the phone must be in use and unlocked, no reader session may be active, and the camera and Wallet must not be in use. It still requires the user to tap the notification. That is the system's version of "tap a tag while walking around", and it is as close to a live mode as iOS offers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Proximity over minutes, hands-free.&lt;/strong&gt; If the requirement is really "know when the user is near a point of interest" rather than "the user touched this thing", NFC is the wrong radio. It works at a few centimetres and only on request. Beacon region monitoring in Core Location, or a Bluetooth LE scan for a service the beacons advertise, work at room scale and in the background, and are what wayfinding systems use. The tags can stay as the deliberate, precise action layered on top.&lt;/p&gt;
&lt;h2 id="handling-the-endings"&gt;Handling the endings&lt;/h2&gt;
&lt;p&gt;Every session ends in &lt;code&gt;readerSession(_:didInvalidateWithError:)&lt;/code&gt;. Treat the error as the reason, not as a failure.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Error&lt;/th&gt;&lt;th&gt;What happened&lt;/th&gt;&lt;th&gt;What to do&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;readerSessionInvalidationErrorFirstNDEFTagRead&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Read one tag, as configured&lt;/td&gt;&lt;td&gt;Nothing; this is success&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;readerSessionInvalidationErrorUserCanceled&lt;/code&gt;&lt;/td&gt;&lt;td&gt;The user tapped Done&lt;/td&gt;&lt;td&gt;Nothing; the user is in charge&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;readerSessionInvalidationErrorSessionTimeout&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Sixty seconds elapsed&lt;/td&gt;&lt;td&gt;Tell the user; offer to start again&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;readerSessionInvalidationErrorSessionTerminatedUnexpectedly&lt;/code&gt;&lt;/td&gt;&lt;td&gt;The app left the foreground&lt;/td&gt;&lt;td&gt;Start again when the user returns and asks&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;readerSessionInvalidationErrorSystemIsBusy&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Another session was active&lt;/td&gt;&lt;td&gt;Wait for it to end; do not retry in a loop&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The one thing not on the list is "restart automatically". A session that begins without the user asking for it is the sheet appearing without warning, and that is the whole reason the request comes up in the first place.&lt;/p&gt;</content:encoded></item></channel></rss>