License Verification at App Launch: A Swift Walkthrough
The moment a user double-clicks your Mac app, the OS is already executing your entry point. Within 50ms, before a single window has rendered, your licensing layer needs to know whether to show the full product, a trial banner, or an activation sheet — and that decision has to be correct without waiting on the network. Getting it right is a UX decision before it is a technical one.
Most apps get it slightly wrong: they call a license check on launch, then show UI. The customer sees a blank window or a spinner while the check runs. That half-second of nothing feels like the app crashed, not like security. The pattern that actually works is different: resolve state immediately from cache, then revalidate in the background. checkOnLaunch() is built around this model, and understanding the order it works through makes the whole system easier to reason about.
Why every launch-time license check is a UX decision
The way your app responds at launch sets the tone. Three common patterns each carry a cost:
The spinning loader — the app opens a window, displays a spinner while hitting the license server, and shows the real UI when it gets an answer. Customers assume the app is broken. Nobody expects their calculator or text editor to contact a server before it opens. A 500ms spinner before the first screen is enough to generate “app is slow” reviews.
The blocked launch — the app does not show anything until the license check resolves. Faster machines with good internet never notice. Customers on slow connections, VPNs, or any network where your license server is unreachable simply see an app that will not open. This is the single-point-of-failure architecture that makes your uptime your customers’ problem.
Grant-then-revoke — the app starts in the paid state by default and revokes if the check fails. This was a common workaround for the spinner problem. It works until a customer with a lapsed subscription sees full features for a second, then watches the UI downgrade mid-session. That experience reads as a bait-and-switch, even when it isn’t.
The pattern that avoids all three: resolve state immediately from a locally verified cached lease — no network, no delay — and let refreshIfNeeded() handle revalidation in the background after the app is already showing the correct UI. The customer never waits, and background revocation still lands when connectivity allows.
What checkOnLaunch() actually does, in order
checkOnLaunch() is a purely local operation on the first pass. The doc comment in LicenseManager.swift lays out the resolution order explicitly across eight enumerated steps; the summary below collapses the lease-status mappings into single items for readability:
-
Clock manipulation check first. If the SDK detects the system clock has been moved backwards — a common bypass attempt —
checkOnLaunch()resolves immediately to.invalid, before reading any stored data. -
Check for a stored license. If the app has a license key in the Keychain, the SDK looks for a cached signed lease alongside it.
-
Read the cached lease via
getCachedLease(). Inside the provider, this reconstructs the canonical payload and verifies the Ed25519 signature against the trusted keyset — if verification fails, the call returnsnil. No network is involved; this is pure local cryptography. The mechanics of why a public-key signature can be verified offline are covered in how offline license validation works. -
Map the verified lease status to a state. If the lease is valid and its status is
"active", state is.licensed. If the status is"fallback"— meaning a subscription-type key that has lapsed but whose key type allows continued fallback access — state is.limited. If the status is"expired", state is.expired. -
If the cached lease has expired OR no cached lease exists at all (e.g. after a Keychain wipe), fall through to online revalidation.
checkOnLaunch()callsvalidateLicense()to get a fresh lease from the server. The server response drives the same status mapping as above. If the network call fails — a flaky coffee-shop wifi, your server having a bad minute — the SDK resolves to.limitedrather than locking out a customer who has a stored license. This is the only network step in the resolution path, and it reflects the fail-open philosophy described in what happens when your licensing server goes down. -
If there is no stored license, fall through to trial logic. The SDK checks the trial’s status:
.active(daysLeft:)maps to.trial(daysLeft:). An expired trial resolves to.freeTierif the product has free tier enabled, or.expiredif not. A trial that has never been started is started now, and state is.trial(daysLeft: trialDurationDays).
Crucially, steps 1–4 have no network dependency at all. For any user who has activated a license and whose cached lease is still valid, checkOnLaunch() completes in a few milliseconds. The UI is never waiting on an external server.
@main
struct WidgetApp: App {
@StateObject private var manager = Licensing.manager
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(manager)
.task { await manager.checkOnLaunch() }
.onChange(of: scenePhase) { phase in
if phase == .active {
Task { await manager.refreshIfNeeded() }
}
}
}
}
}
.task fires when the view appears — the await inside runs on the main actor, keeping all state transitions on the right thread. The onChange block is covered in section 4.
The six states your UI needs to handle
LicenseState has six cases. Each one is a precise description of where the customer is in their relationship with your app, and each calls for a distinct UI response.
.trial(daysLeft: Int) — the customer is within the evaluation window. They have never activated a license key. All paid features should be enabled; this is your opportunity to make the product valuable before the deadline. Surface a banner that counts down as daysLeft approaches zero — “14 days left in your trial” on day one, “3 days left” when it is time to push. The urgency should grow without becoming aggressive.
.licensed — a paid, verified customer. The signed lease is valid, the Ed25519 signature checked out, and the status indicates active entitlement. Show the full app with no trial overlay. A small “Licensed” indicator in preferences is enough; the main window should not belabor the point.
.limited — a lapsed paying customer with fallback access. This state maps from the lease status "fallback", which is emitted when a subscription-type license has expired but the key type was configured with fallbackAccess: true. The customer has been a paying customer — do not treat them like a trial user who never converted. Show a respectful “Renew to restore full access” prompt, keep read-only or export functionality available so they can get their data out, and make the renewal path obvious without gating every interaction behind a paywall.
.freeTier — keyless free-plan access. This is the state after an expired trial when freeTierEnabled: true is set on the factory. There is no stored license and no activated key. Show an upgrade prompt for paid features, but give the free-tier experience without friction. A user in .freeTier has not decided against paying — they just have not decided yet. The distinction between .freeTier (never paid) and .limited (lapsed paying customer) matters: the messaging and the upgrade CTA should be calibrated differently for each.
.expired — the trial window has closed (and no free tier is configured), or a subscription has ended without fallback access. The customer is at the decision point. Show a purchase prompt; gate the paid features. This is the moment the trial-to-paid conversion logic matters most. Make the path from .expired to .licensed as short as possible: a button that opens the purchase URL with one tap, not a flow that requires leaving the app and coming back.
.invalid — no license is stored, the Ed25519 signature failed verification, or clock manipulation was detected. This state covers a range: a brand-new user on their first launch, a user who manually deleted their Keychain entry, and someone who tampered with the lease file. From the UI’s perspective, they all look the same: show the activation sheet. Gate everything that requires a license key.
switch manager.state {
case .trial(let daysLeft):
TrialBanner(daysLeft: daysLeft)
case .licensed:
ProFeaturesView()
case .limited:
LimitedFallbackView() // lapsed subscriber, restricted features
case .freeTier:
FreeTierView() // keyless free experience
case .expired:
RenewalPromptView()
case .invalid:
ActivationSheetView()
}
Swift exhaustive switch means the compiler catches a missing case if Keylight ever adds a new state. Build against the full six-case switch, not a default branch that swallows unknown states silently.
Scene-phase refresh: handling licenses changing while the app is open
checkOnLaunch() sets the initial state. But a Mac app can be open for hours, backgrounded, and resumed — and the license situation may have changed in the gap. A customer could have subscribed on their phone while your app was backgrounded. A refund could have been processed. A subscription could have lapsed on its renewal date while the app was sitting in the Dock.
SwiftUI’s @Environment(\.scenePhase) fires when the scene transitions to .active — meaning the app came to the foreground. Calling manager.refreshIfNeeded() there catches all of those cases:
.onChange(of: scenePhase) { phase in
if phase == .active {
Task { await manager.refreshIfNeeded() }
}
}
refreshIfNeeded() has built-in throttling. It will not hit the network on every foreground event — it checks whether the cached lease is near expiry and whether the last successful refresh was recent enough (debounced at 5 minutes, stale threshold at 6 hours). If neither condition is met, it returns immediately. You can call it freely from onChange without worrying about hammering the validation endpoint.
What refreshIfNeeded() will act on:
- A subscription renewal that minted a fresh lease while the app was suspended. The cached lease may have been near expiry; the new one picks up a fresh validity window.
- A refund processed server-side. On the next online revalidation, the server explicitly rejects the license and the state transitions to
.invalid; a transient network failure duringperformRefresh()is handled differently — the current state is preserved and the next refresh cycle retries silently. - A customer who upgraded from your portal while the app was open.
refreshIfNeeded()picks up the new entitlements and fireskeylightEntitlementsDidChangeif the entitlement set changed — even if the top-levelLicenseStatedid not change (e.g., still.licensed, but now with"pro"incurrentEntitlements). Note thatkeylightEntitlementsDidChangeis intentionally suppressed on the first population at launch; the app readscurrentEntitlementsdirectly at that point and only needs the notification signal for mid-session changes.
Note that refreshIfNeeded() only runs from .licensed, .limited, and .expired — the three states that imply a stored license exists. If the current state is .trial, .freeTier, or .invalid, it returns immediately; those paths are managed by checkOnLaunch(), not the background refresh cycle.
The full launch integration from docs/integration-swift.md shows both calls wired together in the scene setup — the .task for checkOnLaunch() and the .onChange for refreshIfNeeded() — which is the complete setup for a SwiftUI Mac app that handles both initial state and mid-session license changes correctly.
The implementation above is the whole integration surface. No timers, no polling, no explicit network calls: checkOnLaunch() at launch for immediate local resolution, refreshIfNeeded() on foreground for background revalidation with built-in throttling, and a six-case switch that drives the UI exhaustively from whatever state the SDK resolves. If you have questions about specific state transitions or edge cases in your app’s flow, send us your feedback and we’ll keep this post updated.
Frequently asked
When should a Mac app verify the license?+
At launch, and again when the scene becomes active. Verification at launch picks up cached state; the scene-phase refresh catches license changes (refunds, upgrades) that happened while the app was suspended.
What does checkOnLaunch() do under the hood?+
It reads the cached lease from the Keychain, verifies the Ed25519 signature against the embedded public key, checks expiry and revocation, and revalidates online if the cached lease is past its check interval.
What are the six license states the SDK exposes?+
trial (within the trial window), licensed (paid + verified), limited (lapsed paying customer with fallback access), freeTier (post-trial fallback when free tier is enabled), expired (trial or subscription elapsed), and invalid (no license or signature failed). Drive your UI from these.
Ready to ship?
Create your account and start licensing your apps in under a minute. Free forever tier included.
Start Free