Licensing Without License Keys
A license key is a string you ask a human to move from one place to another. That is a user interface decision, not a security one.
The security is in the signature on the entitlement, not in the key itself. Keys are an old habit from an era when the credential had to be typed at a desk, and they still work well for a specific set of situations. But there is a growing class of apps where handing the user a string to paste is the wrong design, and the clearest example is the free tier.
A free-tier user has paid nothing. There is no key to send them, no email to deliver, nothing to activate. Yet you still need to answer one question: has this machine already used its free allowance? If you cannot answer it, “free tier” means unlimited, and every user gets infinite runs by deleting a folder.
That is the problem keyless activation solves. It is a real engineering problem with an uncomfortable privacy dimension, and most apps dodge it by demanding an account signup before the user has received anything of value. That is the wrong tradeoff, and most teams make it because a signup wall was the only thing their licensing system supported.
Where license keys still earn their place
Keys are right whenever the entitlement is an object that changes hands.
Three cases, concretely. First, a purchase the buyer expects to own as a transferable artifact. Someone who buys your app wants proof they can file away, forward to a new machine, or find in an old email three years later. A string is an excellent artifact for that. Second, offline enterprise deployment, where a sysadmin provisions machines that will never reach your server. Third, gifting, where the buyer and the user are different people and the key is the handoff mechanism.
What those share is a moment where the entitlement moves between parties. The key is a bearer token for that transfer, and nothing else does the job as simply. The mechanics of issuing and verifying one are covered in how license keys work, and binding it to specific machines is how device activations work.
Now the cases where a key is pure friction: free tiers, time-limited trials, anonymous usage limits, and any app where the user has not bought anything yet. In all four there is no transfer. Nothing changed hands. You are inventing a credential purely so your system has something to look up, and then making the user carry it for you.
The tell is a trial that requires an account. The user downloaded your app ninety seconds ago. You know nothing about whether it is useful to them. Asking for an email first is charging a price before delivering a value, and trials are the one place where the cost of that friction shows up cleanly in the numbers. Trial to paid conversion is measured against started trials, and a signup wall quietly suppresses the denominator so the rate looks better than the business is.
What machine identity actually is
Machine identity is a stable string that identifies a device across reinstalls, without identifying the person using it.
The hard part is the word stable. An identifier you generate on first launch and write to disk is trivially reset. Delete the app, delete its container, launch again, fresh identity, fresh free tier. That is not a hypothetical attack. It is the first thing a curious user tries, and it takes ten seconds.
So the identifier has to come from below the app. Every desktop and mobile platform provides something. On macOS it is IOPlatformUUID, readable from the IORegistry with no entitlement and no permission prompt. On iOS the equivalent is identifierForVendor. Those values survive app deletion because they do not belong to your app.
Which is exactly why you must not send them anywhere. A raw hardware UUID is a cross-app correlatable identifier. If you transmit it, you have built a tracking primitive whether or not you wanted one, and if you store it, you are holding data you have no use for and would rather not have in a breach.
The fix is one line of thinking: hash it, and salt the hash with your own namespace so the output is meaningless outside your product.
// Derive a per-product machine identity. The raw platform ID never leaves
// the device. Only the digest is transmitted.
static func machineHash(stableID: String, tenantId: String, productId: String) -> String {
let material = "keyless-machine-v1|\(tenantId)|\(productId)|\(stableID)"
let digest = SHA256.hash(data: Data(material.utf8))
return digest.map { String(format: "%02x", $0) }.joined()
}
// And the caller degrades honestly when the platform has nothing stable
// to offer. No fallback fingerprinting.
func machineHashIfAvailable() -> String? {
guard let stableID = device.stableID(), !stableID.isEmpty else { return nil }
return Self.machineHash(stableID: stableID, tenantId: tenantId, productId: productId)
}
Three properties matter here. The version prefix lets you rotate the derivation later without ambiguity. The tenant and product namespace means the same machine produces a different hash for every product, so nothing correlates across apps even if two of them share a backend. And the whole thing is one way: your server receives a 64-character hex string it cannot turn back into a device.
That last point is what makes this defensible rather than merely convenient. You are not storing device information. You are storing a number that happens to be the same number tomorrow.
Deduplication is the real engineering problem
Identity is easy to get wrong in both directions, and both directions have a cost.
Too loose and the free tier is infinite. If your identity is install-scoped, a reinstall mints a new one and the allowance resets. Too strict and you lock out legitimate users. Someone reinstalls their OS, replaces a logic board under warranty, or restores from a backup onto new hardware, and your system decides they are a stranger who has already had their turn.
The design that holds up in practice is to keep both identifiers and treat them as different things. The install ID is the ephemeral one, minted per install, useful for correlating a specific copy of the app. The machine hash is the durable one. When a beacon arrives carrying both, you look up by machine hash first and update the row you find, replacing its stored install ID with the current one. When the hash is absent, because an older client build does not send it or the platform could not provide a stable ID, you fall back to the install ID and accept that deduplication is best effort for that device.
// Machine hash wins when present. The row for this physical machine gets
// updated in place, including its latest install ID, so a reinstall folds
// into the existing device instead of creating a second one.
if (machineHash !== null) {
const updated = await db.update(devices)
.set({ ...fields, instanceId, machineHash })
.where(and(
eq(devices.tenantId, tenantId),
eq(devices.productId, productId),
eq(devices.machineHash, machineHash),
))
.returning({ instanceId: devices.instanceId })
if (updated.length > 0) return
}
// No hash, or no existing machine row: fall back to install-scoped upsert.
await db.insert(devices)
.values({ tenantId, productId, instanceId, machineHash, ...fields })
.onConflictDoUpdate({
target: [devices.tenantId, devices.productId, devices.instanceId],
set: fields,
})
A partial unique index on the machine hash, applied only when it is not null, keeps one row per physical machine per product while leaving hashless legacy rows alone. Do this at the database level, not in application code. Two beacons from the same machine will race eventually, and the constraint is the only thing that reliably wins.
Handle the migration case deliberately. When you ship machine hashing into an app that already has install-scoped rows in the wild, the first hashed beacon from an existing device will find a hashless row holding its install ID. Merge the two: keep the earliest first-seen timestamp, keep the latest last-seen, and delete the redundant row. Skip this and every existing user is counted twice for the rest of the product’s life, which corrupts exactly the numbers you built this to get right. Device counts are one of the licensing metrics worth watching, and a double-counted install base makes all of them lie.
The line you should not cross
Refuse to collect anything that identifies the human rather than the machine.
That means no machine name, because people put their own names in it. No MAC address, no disk serial, no boot volume UUID. No composite browser or system fingerprint assembled from screen resolution, timezone, installed fonts, and locale. No cross-app identifiers. No IP-based location retained beyond the request.
The rule that keeps you honest: could you rebuild anything about the user from what you stored? With a salted one-way hash of a platform-provided ID, the answer is no. You hold an opaque string scoped to your own product. With a fingerprint, the answer is yes, and you have quietly become an adtech company that also sells software.
There is a second rule that matters just as much. When the platform gives you nothing stable, send nothing. The temptation to fall back on a composite fingerprint is exactly the moment where a reasonable system turns into a surveillance one. Accept the degraded case. Some percentage of your free-tier devices will be countable only per install, and that is a better outcome than the alternative.
Offline, and what happens after they pay
A keyless entitlement still has to resolve when the machine cannot reach your server, and the resolution has to be local.
This is where naive implementations break. If the app asks the server “has this machine used its free tier” on every launch, then a plane, a train, or your own downtime turns a working free tier into a paywall. The state has to live on the device: trial start timestamp, current tier, in encrypted local storage that a hostile user cannot trivially rewrite. The server call is then a beacon, not a gate. It reports what happened. It never blocks the user, and a failed request is swallowed rather than downgraded. The same fail-open reasoning applies to paid licenses, covered in how offline license validation works.
Then the user pays, and the last piece matters more than it looks. Their anonymous history should not be orphaned. Send the existing free-tier identifier along with the activation request, so the row that has been beaconing for six weeks gets linked to the new paid license rather than abandoned next to it. Without that link you cannot answer how long free users take to convert, or which free-tier behavior predicts a purchase. This is particularly acute for AI apps, where the free tier is usually a usage quota rather than a feature gate and the conversion signal is buried in usage patterns, which is its own subject in licensing for AI apps.
The payoff for all of this is unglamorous and large: the app opens and works. No key, no email, no signup, no paste. That deletes an entire category of support ticket that exists only because keys exist. Nobody mistypes a key they never received.
Keylight implements this pattern for free tiers and trials alongside conventional keys for paid licenses, because both are the right answer in different places. The keyless beacon carries a salted per-product machine hash, never a raw device ID, and paid entitlements still ship as signed leases that verify offline. Stripe stays your payment processor throughout. See trials for the trial side, pricing for what it costs, or why I built Keylight for the longer argument about why licensing is a separate layer.
The short version: use a key when the entitlement changes hands. Skip it when it does not. Asking a user to paste a string before they have received anything is not security, and it never was.
Frequently asked
What is keyless activation?+
Keyless activation is entitlement without a user-entered credential. The app derives a stable identifier for the machine, hashes it, and the server uses that hash to decide whether this machine has already consumed its free allowance. The user types nothing.
Does machine identity licensing require collecting hardware serials?+
No, and you should not send raw hardware identifiers to your server. Read a platform-provided stable ID locally, mix it with a per-product salt, hash it one way, and transmit only the hash. Your server stores an opaque string it cannot reverse into a device.
What happens to a keyless free tier when the machine is offline?+
The entitlement resolves locally. Keyless state lives in encrypted local storage and the server call is a fire-and-forget beacon, so a failed network request must never downgrade the user. If it does, an airplane turns your free tier into a paywall.
Can a user reset a free tier by reinstalling the app?+
Not if identity is bound to the machine rather than the install. An install-scoped UUID is regenerated by a delete-and-reinstall, which resets the allowance. A machine-scoped hash survives reinstalls, updates, and app-container wipes.
Ready to ship?
Create your account and start licensing your apps in under a minute. Free forever tier included.
Start Free