Swift (macOS)
License keys for Mac apps sold outside the App Store: activate keys, verify Ed25519 tokens offline with CryptoKit, and ship updates with Sparkle.
This guide adds license keys to a Mac app written in Swift, for apps you sell outside the Mac App Store. The customer enters a key once, the app activates it against your Keygate server, and from then on it starts offline from a signed token. Everything below uses Apple's own frameworks: CryptoKit checks the Ed25519 signature, IOKit reads the hardware UUID, so there is no package to add. For the concepts behind each step, see Activating licenses in your app.
The license module
Add this file to your app, fill in your server address and the public key from /api/v1/license/pubkey, and change appName to your app's folder name. It compiles in the Swift 6 language mode and needs macOS 12 or later.
import CryptoKit
import Foundation
import IOKit
enum License {
static let server = URL(string: "https://licenses.example.com")!
static let publicKeyHex = "3b6a27bc..." // from /api/v1/license/pubkey
static let appName = "MyApp"
static let offlineDays: Double = 7 // how long to keep working when the server cannot be reached
struct Claims {
let licenseId: String
let checkBy: Date
let features: [String: Any]
}
struct Failure: Error {
let status: Int // HTTP status, 0 when the server could not be reached
let code: String
}
private struct Saved: Codable {
let licenseKey: String
let token: String
}
// The Mac's hardware UUID, hashed with SHA256, so it is stable but not readable.
static let identifier: String = {
let service = IOServiceGetMatchingService(
kIOMainPortDefault, IOServiceMatching("IOPlatformExpertDevice"))
defer { IOObjectRelease(service) }
let uuid = IORegistryEntryCreateCFProperty(
service, "IOPlatformUUID" as CFString, kCFAllocatorDefault, 0)?
.takeRetainedValue() as? String ?? ""
return SHA256.hash(data: Data(uuid.utf8)).map { String(format: "%02x", $0) }.joined()
}()
private static let file = FileManager.default
.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent(appName)
.appendingPathComponent("license.json")
private static func bytes(hex: String) -> Data? {
var data = Data()
var index = hex.startIndex
while index < hex.endIndex {
let next = hex.index(index, offsetBy: 2, limitedBy: hex.endIndex) ?? hex.endIndex
guard let byte = UInt8(hex[index..<next], radix: 16) else { return nil }
data.append(byte)
index = next
}
return data
}
private static func base64url(_ s: String) -> Data? {
var b64 = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
b64 += String(repeating: "=", count: (4 - b64.count % 4) % 4)
return Data(base64Encoded: b64)
}
static func readToken(_ token: String) -> Claims? {
let parts = token.split(separator: ".").map(String.init)
guard parts.count == 2,
let raw = bytes(hex: publicKeyHex),
let key = try? Curve25519.Signing.PublicKey(rawRepresentation: raw),
let signature = base64url(parts[1]),
key.isValidSignature(signature, for: Data(parts[0].utf8)),
let payload = base64url(parts[0]),
let claims = try? JSONSerialization.jsonObject(with: payload) as? [String: Any],
claims["did"] as? String == identifier,
let lid = claims["lid"] as? String,
let exp = claims["exp"] as? Double
else { return nil }
return Claims(
licenseId: lid,
checkBy: Date(timeIntervalSince1970: exp),
features: claims["ftr"] as? [String: Any] ?? [:])
}
private static func call(_ action: String, _ licenseKey: String) async throws -> String {
var request = URLRequest(url: server.appendingPathComponent("api/v1/license/\(action)"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: [
"license_key": licenseKey,
"identifier": identifier,
"label": Host.current().localizedName ?? "Mac",
])
let (data, response): (Data, URLResponse)
do {
(data, response) = try await URLSession.shared.data(for: request)
} catch {
throw Failure(status: 0, code: "OFFLINE")
}
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
if status == 200, let token = (json?["data"] as? [String: Any])?["token"] as? String {
return token
}
let code = (json?["error"] as? [String: Any])?["code"] as? String ?? "UNKNOWN"
throw Failure(status: status, code: code)
}
private static func save(_ saved: Saved) throws {
try FileManager.default.createDirectory(
at: file.deletingLastPathComponent(), withIntermediateDirectories: true)
try JSONEncoder().encode(saved).write(to: file)
}
static func activate(_ licenseKey: String) async throws -> Claims {
let token = try await call("activate", licenseKey)
guard let claims = readToken(token) else { throw Failure(status: 0, code: "INVALID_TOKEN") }
try save(Saved(licenseKey: licenseKey, token: token))
return claims
}
/// Returns the license claims, or nil when the app is not licensed.
static func check() async -> Claims? {
guard let data = try? Data(contentsOf: file),
let saved = try? JSONDecoder().decode(Saved.self, from: data)
else { return nil }
let claims = readToken(saved.token)
if let claims, claims.checkBy > Date() { return claims }
do {
let token = try await call("verify", saved.licenseKey)
try? save(Saved(licenseKey: saved.licenseKey, token: token))
return readToken(token)
} catch let failure as Failure where failure.status == 404 {
try? FileManager.default.removeItem(at: file)
return nil
} catch {
// No connection: trust the last good token for a few more days.
guard let claims, claims.checkBy.addingTimeInterval(offlineDays * 86400) > Date()
else { return nil }
return claims
}
}
}check() works without a network connection as long as the saved token is fresh. When it is due, the module asks the server again and saves the new token. If the server says the license no longer works on this Mac, the saved key is removed; if the server cannot be reached, the app keeps working for offlineDays longer.
Using it in SwiftUI
Check the license when the window appears and show an activation form if needed:
import SwiftUI
@main
struct MyApp: App {
@State private var licensed: Bool?
var body: some Scene {
WindowGroup {
Group {
switch licensed {
case true?: ContentView()
case false?: ActivationView { licensed = true }
case nil: ProgressView()
}
}
.task { licensed = await License.check() != nil }
}
}
}
struct ActivationView: View {
let onActivated: () -> Void
@State private var key = ""
@State private var message = ""
var body: some View {
Form {
TextField("License key", text: $key)
Button("Activate") {
Task {
do {
_ = try await License.activate(key)
onActivated()
} catch let failure as License.Failure {
message = failure.code // for example ACTIVATION_LIMIT
} catch {
message = error.localizedDescription
}
}
}
Text(message)
}
.padding()
}
}Failure.code is the Keygate error code, or OFFLINE when the server cannot be reached. The error table lists what to tell the customer for each one. claims.features holds the plan's features, so the app can turn parts of itself on and off. See Features and usage limits.
Updates with Sparkle
Keygate serves Sparkle appcasts and signs each release with an Ed25519 key, so licensing and updates live on the same server. Put the product's public signing key in SUPublicEDKey and point SUFeedURL at the product's feed. If the feed requires a license, return the feed address with the saved token as license_token from Sparkle's feedURLString(for:) delegate method. See Shipping updates.
Before you ship
- A sandboxed app needs the outgoing connections entitlement,
com.apple.security.network.client, to reach your Keygate server. - Test with a real license from your dashboard, including what happens when you deactivate the Mac or revoke the license.
- Give customers a way to deactivate from the app, so they can move to a new Mac without writing to you.
Last updated October 4, 2026