A License System With Zero Backend
A Pier postmortem: one-time purchase, 5 devices, refund revocation — the whole licensing system is under 200 lines of client-side Swift with no server and no embedded secrets. The interesting parts: where state lives, and who to believe when validation fails.
Pier 2.0 goes paid, which means it needs a license system: 14-day trial, key activation, a 5-device limit, revocation after refunds. There was exactly one constraint — I refuse to operate a license server for a menu-bar tool. It would need to be up 24/7, abuse-resistant, backed up, and when it breaks, every user’s activation breaks with it. That liability is wildly out of proportion to the product.
The whole thing ended up being under 200 lines of client-side Swift plus two HTTP endpoints the payment provider already runs.
The payment provider is the backend
I picked Dodo Payments for two properties. First, Merchant of Record: they absorb cross-border tax compliance (VAT/GST), which for a solo developer is a real burden lifted. Second, a built-in License Key API: payment automatically issues a key, and the client only ever talks to two public endpoints:
POST /licenses/activate— exchanges a key for an activation instance id (the device limit is enforced here);POST /licenses/validate— checks whether key + instance are still good (refund revocation shows up here).
The key property: both endpoints are self-authenticating via the license key — the key is the credential, no API secret rides along. Which means the client ships with zero embedded secrets; there is no “extract the secret from the binary and forge activations” attack because there’s nothing to extract.
Where state lives: the keychain, not UserDefaults
The local state machine has three states:
enum Status: Equatable {
case trial(daysLeft: Int)
case activated
case expired
}
Both the trial start date and the activation credentials live in the keychain rather than UserDefaults, for one reason: defaults delete is a one-line infinite trial reset, while keychain items survive deleting and reinstalling the app. This isn’t anti-piracy (see below) — it just raises the cost of resetting the trial from one shell command to a deliberate trip into Keychain Access. Zero friction for honest users, just enough for casual freeloading.
Failure semantics: generous to users, strict about evidence
On launch, an activated license gets silently revalidated. This hides the single most important decision in the system — who do you believe when validation fails:
/// Returns true = valid, false = confirmed invalid (200 with valid:false, or 4xx);
/// network/server errors throw — caller ignores them, state unchanged.
if let valid = try? await Dodo.validate(licenseKey: key, instanceID: instanceID),
valid == false {
deactivate()
}
The rule: only definitive evidence of invalidity (the server explicitly saying valid: false, or a 4xx) revokes local activation. Network down, timeout, 5xx — keep the current state. Opening your Mac on a plane should never show “license expired”; ten minutes of payment-provider downtime should never de-activate every paying customer. Wrongly punishing one paying user costs far more than letting one refunded user coast for two extra days.
Testability: every state needs a direct door
The worst thing about license code is that its states are hard to reproduce — “expired” takes 14 days, “activated” takes real money. The answer is an environment-variable backdoor:
// PIER_FORCE_STATUS=expired|trial|activated — bypasses the keychain entirely.
// Only takes effect when launched from a shell with the env var; invisible to normal users.
switch ProcessInfo.processInfo.environment["PIER_FORCE_STATUS"] {
case "expired": status = .expired; return
...
All three paywall states are one terminal command away, and UI debugging never touches real data. Test/live is likewise a single boolean that flips the API host and the checkout link together — there is no “changed one, forgot the other.”
The honesty clause
None of this stops a determined cracker — it’s a local state machine; patching the binary or forging a keychain item gets around it. But the threat model was never DRM: strict server-side enforcement for an offline tool means punishing 99% of paying users with a network dependency to inconvenience the 1% who’d never pay anyway. The lock exists to give the door a shape. People willing to use the door will use the door.
Takeaways
- Don’t build backends you can buy. A license server is the canonical “build once, operate forever” liability, and the provider’s public endpoints absorb all of it;
- Self-authenticating endpoints are the right shape for zero-trust clients. The key is the credential; there is nothing in the binary worth reverse-engineering;
- Failure semantics matter more than the happy path. One conditional — “network errors keep current state” — defines the entire offline and outage experience;
- A state you can’t reach in one command is a state you haven’t tested.
Comments