Skip to main content
Migrate Already selling? Move your customers to Keylight without re-issuing a single key.
Keylight
Blog
macos keychain sparkle

Persisting Mac App Licenses Across Auto-Updates

7 min read Nicolas Demanez — Founder Updated July 31, 2026

Auto-updates replace your .app bundle wholesale, and where the license lives decides whether the customer notices. Anything stored outside the bundle survives; 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?.

That leaves two locations that genuinely survive an update: a file in Application Support, and the Keychain. Neither is inside the bundle, and neither is in a path the updater touches.

Where the Keylight SDK puts it

Since SDK 0.6.0 the default backend is a device-bound encrypted file, not the Keychain:

<Application Support>/.keylight/<tenantId>/.kl

The blob is a 4-byte KL01 magic followed by an AES-256-GCM sealed box, keyed with HKDF-SHA256 from the device identity — the IOPlatformUUID on macOS — with your tenantId and productId folded into the HKDF info. That derivation is what matters for updates: none of the three inputs is the binary. A new .app at a new path, built on a different machine, signed in a different session, derives exactly the same key on the customer’s Mac and opens exactly the same file. The signed lease written by v1 is readable by v2 with no migration step.

The default changed in 0.6.0 for a reason worth knowing if you are reading older posts about this: a Keychain-authoritative store made macOS show a permission prompt on first launch. The file backend removes that prompt. If you prefer the old behavior — you read license state outside the SDK, or want it to ride along with Keychain sync and restore — storage: .encryptedFile(keychainMirror: true) keeps the file authoritative with a Keychain recovery copy, and storage: .keychain restores the pre-0.6.0 arrangement outright.

When the Keychain is in play, the mechanics are the ones that make it update-proof in the first place. Keychain Services is a system-level database living in /Library/Keychains/ and ~/Library/Keychains/, and what makes an item retrievable isn’t the bundle’s path on disk — it’s the kSecAttrService and kSecAttrAccount attributes used when it was written. The SDK writes one generic-password item per tenantId+productId, under a service prefix scoped to dev.keylight.<tenantId>. When the new .app launches and queries the same service and account, the OS returns the same entry; it neither knows nor cares that the binary reading it came from a different path than the one that wrote it.

On macOS the SDK uses the standard Keychain Services API and 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 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.

Whichever backend you choose, the lease itself is Ed25519-signed by the server and re-verified locally on every read. Decryption succeeding is not the same as the lease being trusted — a tampered lease fails the signature check even if an attacker gets past the file’s authentication tag.

Sparkle-specific gotchas: paths, identity, and the sandbox

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 reads and writes its own file; 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 — which is exactly why path-independent storage is the requirement. The staged copy and the live copy resolve to the same license file and the same Keychain item.

The footgun on the default file backend is the bundle identifier, and only under the sandbox. Unsandboxed, <Application Support> is ~/Library/Application Support — shared across the user, so the license path is stable no matter what your bundle ID is. Sandboxed, the same API returns ~/Library/Containers/<bundle-id>/Data/Library/Application Support. Change the bundle identifier and you change the container; the new build looks in an empty directory and the license appears gone. The same applies to migrating an unsandboxed app into the sandbox: the path moves, and the old file is left behind in the user’s home.

Note what is not on that list: your Team ID. Because the file key derives from hardware identity and your tenant and product IDs, transferring a project between developer accounts does not break the default backend. That was the classic Keychain footgun — items written without an explicit access group are scoped by macOS to <TeamID>.<bundle-identifier>, so a Team ID change silently pointed the new binary at a group it had never written to, and the license read as missing while the data sat under the old group identifier. If you have opted into a Keychain backend, that hazard still applies to you and the same Team ID must carry across every version that needs the same items.

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 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 sealed license file exists on disk, written by the v1 build.

Step 2: Install v2 over v1 without wiping state. Build your new version with the same bundle identifier (and, if you use a Keychain backend, the same Team ID). 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 the stored lease, verify its Ed25519 signature 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 bundle identifier changed between versions so the sandbox container moved, or — on a Keychain backend — the item was created under a different Team ID. If the assertion passes, you’re done: the stored lease 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, which is precisely where the failure mode lives, and you want to verify under the distribution configuration 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 storage layer doesn’t care how the new binary arrived; it cares that the identifiers it derives from haven’t moved. 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 shared license state, 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, an updater that replaces the bundle wipes it. Licenses kept outside the bundle — in Application Support or the Keychain — survive replacement, because the updater never touches those paths.

Where does the Keylight SDK store the license?+

By default, in a device-bound AES-256-GCM encrypted file under Application Support, sealed with a key derived from the device identity plus your tenant and product IDs. The Keychain is only used if you opt in with keychainMirror: true or .keychain.

Do I need to do anything special for Sparkle to preserve my license?+

No, as long as the license lives outside the .app bundle. Sparkle replaces the bundle atomically and never touches Application Support or the Keychain. 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