Persisting Mac App Licenses Across Auto-Updates
Auto-updates replace your .app bundle wholesale, and where the license lives decides whether the customer notices. Keychain-stored licenses survive; bundle-local storage doesn’t. If you store license state in the wrong place, every auto-update silently resets activation for everyone on that machine.
What happens to license state when a .app is replaced
An auto-updater like Sparkle follows a predictable sequence: it downloads the new build, verifies the code signature, stages the new .app, performs an atomic move into the Applications folder, and relaunches the app. That atomic move is the moment of danger. The old .app bundle is gone and the new one is in its place.
Anything stored inside the bundle — data in MyApp.app/Contents/ — vanishes with the swap. That sounds obvious, but it catches developers who use Bundle.main.bundlePath to construct file paths for preference or license storage. Those paths are inside the bundle tree, and they don’t survive the move.
UserDefaults sits in an interesting middle position. The defaults domain is stored in the user’s Library, not inside the bundle, so UserDefaults survives a simple bundle replacement. However, UserDefaults is still bound to the app’s container. When you migrate an unsandboxed app into the Mac App Sandbox — or change your app’s bundle identifier — the defaults domain changes and the existing entries become invisible. A customer who has been on your app for years through direct distribution, then downloads a sandboxed Mac App Store version, looks unlicensed in the new build even though they paid. For more on how the sandbox complicates storage decisions, see Does Keylight Work in Sandboxed Mac Apps?.
The only storage layer that’s genuinely location-independent is the macOS Keychain. It doesn’t care about your bundle path, your container, or the Applications folder.
Why Keychain survives bundle replacement
Keychain Services is a system-level database, not a file inside your app. It lives in /Library/Keychains/ and in the user’s own keychain at ~/Library/Keychains/. When your app is replaced by an updater, those files are untouched. They aren’t part of the .app bundle and they aren’t in any path the updater touches.
What makes a Keychain item retrievable by your app isn’t the bundle’s path on disk — it’s the kSecAttrService and kSecAttrAccount attributes you used when you wrote it. When the new .app launches and calls SecItemCopyMatching with the same service and account values, the OS returns the same entry. The entry doesn’t know or care that the binary reading it came from a different path than the binary that wrote it.
The Keylight SDK makes this concrete: it uses one generic-password Keychain item per tenantId+productId combination, keyed by a service prefix scoped to dev.keylight.<tenantId>. That key pair stays stable across updates as long as your tenant and product IDs don’t change — which they shouldn’t. The signed entitlement document (a signed lease) written by v1 of your app is readable by v2 with no migration step.
The SDK also maintains a two-layer storage model, which docs/security.md covers in detail. The Keychain is the authoritative layer — all reads and writes prefer it. Alongside it, the SDK writes an XOR-obfuscated file fallback at ~/Library/Application Support/.keylight/<tenantId>/. Under the App Sandbox, that path resolves into the app’s container Application Support directory rather than the user’s actual home — but the principle is unchanged. The file fallback exists for one specific scenario: Keychain can be wiped by app re-signing, user-driven resets, or certain OS migrations. In that case, the file layer preserves the customer’s signed lease so they don’t lose their entitlement. Both layers survive a bundle replacement because neither is inside the .app. The obfuscated file is not tamper-resistant — the XOR key is in the SDK source — but every lease inside it is Ed25519-signed by the server and re-verified on every read, so editing the file doesn’t help an attacker.
One thing to be explicit about: the SDK on macOS uses the standard Keychain Services API. It does not set kSecUseDataProtectionKeychain. The Data Protection Keychain on macOS requires the keychain-access-groups entitlement, which isn’t available to all build configurations. The standard API gives you the same persistence guarantees for update survival without that constraint. For a fuller discussion of where to put license state, see where to store license data on macOS.
Sparkle-specific gotchas: sandbox + temporary install paths
This section is about Sparkle specifically, but the principles apply to any auto-updater. To be direct: Keylight does not ship a Sparkle integration. The SDK just writes to Keychain; Sparkle does its own thing with the bundle. They don’t interact.
The thing to understand about Sparkle’s update mechanism is that the new .app is staged in a temporary location before the atomic move. Sparkle downloads the update to a path like ~/Library/Caches/Sparkle_<UUID>/, verifies the signature there, and only then replaces the live copy in Applications. During staging, the temporary app has a different path than the live app. If you’re tracing file system activity during an update, the Keychain reads from the staged copy and the live copy look the same — same service, same account — because Keychain access is identity-agnostic to path.
The footgun is code-signing identity. Keylight writes Keychain items without an explicit access group, which means macOS scopes them to the app’s default group — <TeamID>.<bundle-identifier>. Change either component (most commonly the Team ID when a project transfers between developer accounts) and the new binary’s default group changes too; it silently looks in the wrong group and never sees the existing item. The license appears gone, but the data is still there under the old group identifier. The same Team ID and signing identity must carry across all versions that need access to the same Keychain items. Maintaining the same bundle identifier is also required; changing it is equivalent to installing a different app.
Under the App Sandbox, Sparkle’s update helper inherits the app’s sandbox profile. This is generally fine, but it means your sandbox entitlements need to be consistent across versions — if v2 requests a narrower entitlement set than v1, the sandbox may restrict what Sparkle’s helper can do during the transition. In practice, for a Keychain-backed license, the relevant entitlement is com.apple.security.network.client for the revalidation call, and that’s a standard entitlement any networked app already has.
Verifying persistence: a three-step test before shipping an update
Running this test before shipping any update protects you from discovering the problem after thousands of customers auto-update. It takes less than ten minutes.
Step 1: Install v1, activate a license. Build and run your current release. Enter a valid license key and confirm manager.state == .licensed. You’re establishing a baseline — a Keychain item exists with the signed lease, written by the v1 build.
Step 2: Install v2 over v1 without wiping state. Build your new version with the same Team ID and same bundle identifier. Replace the running app — either drag the new build over the old one in Applications or let Sparkle install it using your update feed. Do not delete and reinstall; that would test a clean install, not an update. The point is to simulate the exact path a customer’s machine will take.
Step 3: Launch v2 and assert. Launch the updated app. Call checkOnLaunch() to read from Keychain, verify the Ed25519 signature on the cached lease locally, and resolve state. Assert that the license is still valid:
// In the updated build, on first launch:
await manager.checkOnLaunch()
assert(manager.state == .licensed, "License did not persist across the update")
If this assertion fails, you have a storage problem. The most common causes are: the license was written to a bundle-local path, the Keychain item was created under a different Team ID, or the kSecAttrService value changed between versions because it was constructed from a mutable identifier. If the assertion passes, you’re done — the Keychain entry survived the bundle swap and the customer won’t notice the update.
One note on timing: run this test on a sandboxed build if you ship to the Mac App Store, not just on your development build. The sandbox changes the container path for the file fallback layer, and you want to verify both layers are working correctly under the distribution configuration that customers actually use.
The pattern scales to any update mechanism. If you use a custom updater, a CI-triggered .dmg deployment, or a manual drag-replace workflow, the same three steps apply. The Keychain doesn’t care how the new binary arrived; it cares about the signing identity and the attribute values. Get those right in v1 and they’ll be right in every version that follows.
If you run into an edge case that this guide doesn’t cover — a Team ID transfer, a multi-target app where different helpers need Keychain access, a Sparkle configuration that’s behaving unexpectedly — send us your feedback and we’ll extend this post. For the full picture of how Keylight issues and verifies licenses, the license keys feature page has the details.
Frequently asked
Does a Mac app lose its license when it auto-updates?+
It depends on where the license is stored. If it lives inside the .app bundle (UserDefaults, plist files), an updater that replaces the bundle can wipe it. Keychain-stored licenses survive bundle replacement because the Keychain is system-level, not bundle-level.
Why does the Keylight SDK use Keychain instead of UserDefaults?+
Keychain entries persist across app reinstalls and bundle replacements, are encrypted at rest, and survive the kind of file-system churn an auto-updater triggers. UserDefaults is convenient but is bound to the bundle, which can lose data during sandbox migrations.
Do I need to do anything special for Sparkle to preserve my license?+
No, as long as you store the license in the Keychain. Sparkle replaces the .app bundle atomically; system services like Keychain are unaffected. The license is available again the moment the updated app launches.
Ready to ship?
Create your account and start licensing your apps in under a minute. Free forever tier included.
Start Free