Writing PrivacyInfo.xcprivacy Without Guesswork: How My Browser-Based Privacy Manifest Generator Models Apple's Rules

The four keys, the seventeen required-reason codes, the consistency checks that catch half-finished manifests, and why round-tripping an existing file matters more than generating a new one

By Alok Singh (@aloksinghdev.bsky.social)
Published:

Originally published at https://bestlatestapps.com/blog/writing-privacyinfo-xcprivacy-without-guesswork/.

The four keys, the seventeen required-reason codes, the consistency checks that catch half-finished manifests, and why round-tripping an existing file matters more than generating a new one

Since 2024, Apple has asked apps and third-party SDKs to describe their data use in a privacy manifest: a property list called PrivacyInfo.xcprivacy. Apps that use certain system APIs, the "required-reason APIs", have to declare why. Most iOS apps hit at least one, because UserDefaults is on the list.

Xcode has a template for the file and a property-list editor, but the editor shows raw keys and makes you look up reason codes like CA92.1 in the documentation. I built a privacy manifest generator for my site so the choices are checkboxes with plain descriptions, and the output is a file you can drop into a target.

This article explains how the tool models the manifest and what it checks, using its real source, privacy-manifest.js. Snippets are taken or trimmed from that file. I also checked the tool's list of reason codes against Apple's documentation while writing; more on that below. What the tool can't do for you, which is know what your binary actually calls, gets its own section.


1. What a privacy manifest contains

A privacy manifest is a dictionary with four top-level keys:

| Key | Type | What it says | |-------------------------------|-----------------------|----------------------------------------------------------------------------------------------------------------------| | NSPrivacyTracking | Boolean | Whether the app or SDK uses data for tracking, as Apple's App Tracking Transparency rules define it. | | NSPrivacyTrackingDomains | Array of strings | Domains contacted for tracking. iOS can block them until the person allows tracking. | | NSPrivacyCollectedDataTypes | Array of dictionaries | Each data type collected, whether it's linked to the person, whether it's used for tracking, and why it's collected. | | NSPrivacyAccessedAPITypes | Array of dictionaries | Each required-reason API category used, with one or more approved reason codes. |

When you archive, Xcode combines the manifests of your app and its SDKs into a privacy report. The data types section should line up with the privacy "nutrition label" you fill in on App Store Connect. That's the real value of getting the file right: it's one source of truth that the report, the label and App Review can all be checked against.


2. Modelling Apple's vocabulary as data

The tool's first design decision is that Apple's vocabulary lives in three plain arrays at the top of the file, not scattered through UI code. Collected data types are grouped the way Apple groups them:

`` javascript var TYPES = [ ["Contact info", [["Name", "Name"], ["EmailAddress", "Email address"], ["PhoneNumber", "Phone number"], ["PhysicalAddress", "Physical address"], ["OtherUserContactInfo", "Other contact info"]]], ["Health and fitness", [["Health", "Health"], ["Fitness", "Fitness"]]], // ... Financial info, Location, Sensitive info, Contacts, User content, Browsing and search, // Identifiers, Purchases, Usage data, Diagnostics, Surroundings and body, Other ]; var PURPOSES = [["ThirdPartyAdvertising", "Third-party advertising"], ["DeveloperAdvertising", "Developer's advertising"], ["Analytics", "Analytics"], ["ProductPersonalization", "Product personalisation"], ["AppFunctionality", "App functionality"], ["Other", "Other purposes"]]; CODEBLOCK0 javascript var APIS = [ ["UserDefaults", "User defaults (UserDefaults, NSUserDefaults, @AppStorage)", [ ["CA92.1", "Read and write data that only this app can access."], ["1C8F.1", "Read and write data shared with apps, extensions and App Clips in the same App Group."], ["C56D.1", "Third-party SDK: a wrapper the app calls, using only data the app passes in."], ["AC6B.1", "Read the MDM managed app configuration, or store feedback for MDM to query."]]], ["FileTimestamp", "File timestamps (creationDate, modificationDate, stat, getattrlist)", [ /* C617.1, DDA9.1, 3B52.1, 0A2A.1 */ ]], ["SystemBootTime", "System boot time (systemUptime, mach_absolute_time)", [ /* 35F9.1, 8FFB.1, 3D61.1 */ ]], ["DiskSpace", "Disk space (volumeAvailableCapacity, statfs)", [ /* E174.1, 85F4.1, 7D9E.1, B728.1 */ ]], ["ActiveKeyboards", "Active keyboards (activeInputModes)", [ /* 3EC4.1, 54BD.1 */ ]] ]; CODEBLOCK1 javascript function plist(st) { var t = "\t", L = ['<?xml version="1.0" encoding="UTF-8"?>', '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">', '<plist version="1.0">', "<dict>"]; L.push(t + "<key>NSPrivacyTracking</key>", t + bool(st.tracking)); L.push(t + "<key>NSPrivacyTrackingDomains</key>"); if (st.domains.length) { L.push(t + "<array>"); st.domains.forEach(function (d) { L.push(t + t + "<string>" + x(d) + "</string>"); }); L.push(t + "</array>"); } else L.push(t + "<array/>"); // ... NSPrivacyCollectedDataTypes and NSPrivacyAccessedAPITypes in the same style L.push("</dict>", "</plist>", ""); return L.join("\n"); } CODEBLOCK2 xml <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>NSPrivacyTracking</key> <false/> <key>NSPrivacyTrackingDomains</key> <array/> <key>NSPrivacyCollectedDataTypes</key> <array> <dict> <key>NSPrivacyCollectedDataType</key> <string>NSPrivacyCollectedDataTypeCrashData</string> <key>NSPrivacyCollectedDataTypeLinked</key> <false/> <key>NSPrivacyCollectedDataTypeTracking</key> <false/> <key>NSPrivacyCollectedDataTypePurposes</key> <array> <string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string> </array> </dict> </array> <key>NSPrivacyAccessedAPITypes</key> <array> <dict> <key>NSPrivacyAccessedAPIType</key> <string>NSPrivacyAccessedAPICategoryUserDefaults</string> <key>NSPrivacyAccessedAPITypeReasons</key> <array> <string>CA92.1</string> </array> </dict> <dict> <key>NSPrivacyAccessedAPIType</key> <string>NSPrivacyAccessedAPICategoryFileTimestamp</string> <key>NSPrivacyAccessedAPITypeReasons</key> <array> <string>C617.1</string> </array> </dict> </array> </dict> </plist> CODEBLOCK3 javascript function warnings(st) { var w = []; if (st.tracking && !st.domains.length) w.push("Tracking is on. List the domains your app or SDKs use for tracking, so iOS can block them until the person allows tracking."); if (!st.tracking && st.domains.length) w.push("Tracking domains are listed while tracking is off. Turn tracking on, or clear the domains."); Object.keys(st.types).forEach(function (k) { var d = st.types[k]; if (!d.purposes.length) w.push(k + ": choose at least one purpose."); if (d.tracking && !st.tracking) w.push(k + " is marked as used for tracking, so turn on tracking at the top too."); }); return w; } CODEBLOCK4 javascript (o.NSPrivacyAccessedAPITypes || []).forEach(function (d) { var c = String(d.NSPrivacyAccessedAPIType || "").replace("NSPrivacyAccessedAPICategory", ""); if (!c) return; var known = APIS.filter(function (a) { return a[0] === c; })[0], codes = (d.NSPrivacyAccessedAPITypeReasons || []).map(String); st.apis[c] = codes; codes.forEach(function (r) { if (!known || !known[2].some(function (k) { return k[0] === r; })) unknown.push(c + " " + r); }); }); ` Unknown codes, perhaps a code Apple added after the tool's table was written, aren't dropped. A separate extras() function collects them, and read() merges them back into the state every time the output is regenerated. The status line tells you which entries were kept as they were. Silently deleting a reason code you don't recognise would be the worst possible behaviour for an editing tool, because the file would still be valid and the loss would be invisible. The file input is cleared as soon as the file is read, and a file that isn't a readable XML property list gets a plain error ("This file is unreadable as an XML property list") instead of a half-filled form. ------------------------------------------------------------------------ ## 6. Defaults and persistence On a first visit, the form starts with one thing ticked: UserDefaults with reason CA92.1, "read and write data that only this app can access". That's not a guess about your app. It's a starting point that matches the most common case, and you untick it if it doesn't apply. The page FAQ makes the related point: if you use any required-reason API, you need a manifest even when you collect no data. Your choices are saved in the browser's localStorage under one key, so they're there next time you open the page. The page says so in its privacy note, and the save is wrapped in a try/catch so private-browsing modes that refuse storage don't break the tool. The manifest is built inside the page from what you tick; the tool's code doesn't send your choices anywhere. Download produces a Blob named PrivacyInfo.xcprivacy, with a reminder to add it to your app target in Xcode. ------------------------------------------------------------------------ ## 7. What a generator can't know This is the section I'd want every privacy-manifest tool to include. A form can make the file correct. It can't make it *true*. Only you know what your binary and its dependencies do. Here's how I'd approach the audit. It's general advice, not something the tool does for you: - **Search your own code for the API families.** UserDefaults and @AppStorage; creationDate, modificationDate and stat; systemUptime and machabsolutetime(); volumeAvailableCapacity keys; activeInputModes. The tool's category descriptions name the common entry points, which makes a good search list. - **Remember property wrappers and helpers.** @AppStorage is UserDefaults. A "time since launch" helper is probably system boot time. Measuring intervals with machabsolutetime falls under 35F9.1. - **Collect your SDKs' manifests.** Third-party SDKs are expected to ship their own PrivacyInfo.xcprivacy. Your app's manifest covers your code; theirs covers theirs. Update SDKs that don't ship one yet. - **Generate Xcode's privacy report** from an archive and read it as a whole. It's the combined view, and it's what should match your App Store Connect privacy label. - **Revisit it with every new dependency.** Analytics, crash reporting and ad SDKs are the usual sources of new data types. If you also ship ads, two related tools on my site help with the rest of that paperwork: the [app-ads.txt generator and checker](https://bestlatestapps.com/tools/app-ads-txt/) and the [eCPM calculator](https://bestlatestapps.com/tools/ecpm-calculator/). ------------------------------------------------------------------------ ## 8. Picking reasons: the cases people ask about The reason codes are terse, so here's how the tool's descriptions map to everyday situations. The final call is yours, checked against Apple's documentation, but these are the common situations: | Situation | Category | Code | |---------------------------------------------------------------------|------------------|----------| | Settings and flags stored by your app only | UserDefaults | CA92.1 | | A widget or extension reads the app's settings through an App Group | UserDefaults | 1C8F.1 | | You're an SDK wrapping UserDefaults for the host app | UserDefaults | C56D.1 | | Checking the age or size of your own cache files | File timestamps | C617.1 | | Showing "modified" dates in a file browser | File timestamps | DDA9.1 | | Timing events or running timers inside the app | System boot time | 35F9.1 | | Checking free space before writing a large download | Disk space | E174.1 | | A custom keyboard app checking which keyboards are active | Active keyboards | 3EC4.1 | Multiple reasons per category are allowed and common. An app that stores its own settings *and* shares some with a widget declares both CA92.1 and 1C8F.1`.


9. Before you submit: a privacy manifest checklist


Wrapping up

A privacy manifest is a small file with a large blast radius. The tool's job is to make the format impossible to get wrong: Apple's vocabulary as data, hand-written XML with escaping, consistency warnings, and an import path that never throws away what it doesn't understand. Your job is the part no tool can do: knowing what your app and its SDKs actually do.

Try it, or browse the related tools:

Audit your dependencies as well as your own code: an SDK's missing manifest is still your rejection email.

— Alok Kumar Singh