Hardware-bound licensing for self-hosted software, with offline grace
Self-hosted software has a licensing problem most people handwave. The two common shapes — "trust the user" (no licensing, ship binaries) or "phone home constantly" (every launch hits a cloud server, fails closed if the server is down) — both suck for someone running on their own infrastructure. The first has obvious revenue gaps; the second turns your customer into a support ticket the day your auth service has a hiccup or their internet goes out.
Building KULVEX, a self-hosted AI platform people install on their own hardware, we needed a third option: a licence that's real (we want to get paid for the work) but that doesn't hold the user's software hostage to the licence server's uptime. Same shape needs to work for KCode, our AI coding CLI for paid tiers. This post is the design we landed on.
What "respectful licensing" means, concretely
We started by writing down what would make us, as users, stop using a piece of software:
- Failing closed on outage. If the vendor's licence server is down, our software should keep working. We paid; they have an outage; we should not be punished.
- Phoning home with content. The licence check should not double as a telemetry pipe. It needs the licence key and a way to identify the install — nothing more.
- Punishing legitimate hardware moves. Replacing a dying disk, upgrading a CPU, swapping a motherboard — none of these should brick the licence. Real hardware migrations happen.
- Being un-uninstallable. The user should be able to remove the software and have it actually go away, including any DRM hooks.
And then what would make us, as the vendor, lose money:
- One licence on a dozen machines. The licence is per-install, not per-customer. The simplest way to enforce that is hardware binding.
- No way to revoke. If a licence is shared in an obvious way, we should be able to refuse renewal.
The two lists fit together if you bind the licence to hardware, validate periodically (not constantly), and grant a long offline grace window.
The hardware fingerprint
The fingerprint is a hash of stable hardware identifiers. We use:
- The first non-loopback MAC address (lowercase, normalised)
- The CPU model string + core count (from
/proc/cpuinfoor sysctl) - The motherboard serial number when readable (
dmidecode), skipped on platforms where it isn't - The system UUID (
/sys/class/dmi/id/product_uuidon Linux)
Concatenated, salted with a fixed application namespace, and SHA-256'd. The salt means the same hardware running a different product produces a different fingerprint — there's no fleet-wide identity that could be cross-referenced.
def hardware_fingerprint() -> str:
parts = [
_first_non_loopback_mac() or "",
_cpu_model() + ":" + str(_cpu_cores()),
_motherboard_serial() or "",
_system_uuid() or "",
]
raw = "kulvex-v1::" + "::".join(parts)
return hashlib.sha256(raw.encode()).hexdigest()The output is a 64-char hex string. It identifies the install, not the user. It changes when the user replaces the motherboard or significantly changes the network interfaces — but two of the four signals must change for the fingerprint to flip, so a single component swap doesn't break things.
Validation cadence and the offline grace window
The validation flow is conservative. The product doesn't check the licence on every launch; it checks on first activation, and then periodically thereafter:
- First activation. User pastes their licence key. The product hits
POST /v1/activatewith the key + the local hardware fingerprint. The server records the binding, returns a signed JWT containing the licence, the bound fingerprint, the tier, and an expiry. The JWT is stored locally. - Daily (background). The product checks the local JWT's expiry. If still valid, no network call. Cached entitlements drive feature access.
- Heartbeat (default: every 60 days). The product hits
POST /v1/heartbeatwith the JWT. The server confirms the licence is still active (not refunded, not cancelled, not flagged) and returns a refreshed JWT. - If heartbeat fails. The product enters the offline grace window: 60 days from the last successful heartbeat. During the window everything works normally — the user sees a small banner reminding them to come online when convenient.
- After grace expires. The product falls back to a degraded mode (read-only access to local data + the dashboard, but new agents can't be created and the AI tools are paused). It does not wipe data and does not refuse to launch.
The 60-day window is intentional: long enough that an outage on either side (vendor server down, customer's ISP misbehaving, customer travelling without internet) never causes a real problem, short enough that a cancelled licence stops being usable in a reasonable timeframe.
Rebinding: the legitimate-migration path
Hardware fingerprints change when hardware does. Real customers do the following often enough that a rigid binding is wrong:
- Replace a dying SSD (changes nothing in our hash — fine)
- Add a second NIC (changes MAC if it becomes the first non-loopback — flips)
- Swap motherboard (changes serial + system UUID — flips)
- Migrate to a new machine entirely (everything changes)
The rebind flow:
- User triggers
kulvex licence rebindon the new (or modified) hardware, or clicks the rebind button in the dashboard. - The product hits
POST /v1/rebindwith the licence key + the new fingerprint. - The licence server checks the rebind quota for the tier (typical: 3 rebinds in any rolling 30-day window for the personal tier; unlimited for higher tiers). If under the limit, the binding is updated and a new JWT issues. If over, the user gets a rate-limit error with a clear message that contacting support unlocks a manual rebind.
The rate limit exists to prevent the licence from being shared across many machines via fast rebinds. The window is generous enough that legitimate migrations never hit it; the rare user who does is a few machines-per-month away from a real-person conversation.
What we deliberately left out
- Code obfuscation / anti-tamper. The product is shipped as readable Python and TypeScript. A determined attacker can patch the licence check. The point of licensing isn't to defeat the determined attacker — it's to make the casual path of paying easier than the casual path of cracking. Fingerprint + heartbeat + a clear ToU does that.
- TPM / Secure Boot binding. Strictly more secure, but rules out a long tail of legitimate hardware (older boxes, virtualised installs, NUCs without TPM 2.0). The cost-benefit didn't cross for a self-hosted product.
- Phone-home telemetry. The heartbeat carries the licence key, the fingerprint, and the version. No usage stats. No agent counts. No chat counts. We could measure those, technically. We chose not to — see the private-by-default post for the broader stance.
- Online-required activation flows. Ironic but real: the install can complete without the activation server reachable. The user gets a 14-day trial state and can activate later. The trial is ungated; the licence-server outage doesn't hold up onboarding.
Things this approach doesn't solve
- VM / container fingerprint instability. In some virtualised setups, the "hardware" fingerprint reflects host-side virtualisation rather than the guest's persistent identity. We document the exact signals so virtualised customers can make their guest stable, and the rebind quota covers the rest.
- Determined sharing. Two customers who share a licence and rotate the rebind across their machines can probably stretch a single licence for a while. Tier quotas slow this down; renewal gates close the loop. We accept that perfect enforcement is incompatible with respectful licensing.
- Air-gapped installs without licence-server reach ever. Some users need this. The current design degrades after 60 days. For genuinely air-gapped customers we issue a long-validity offline JWT that bypasses heartbeats — a separate, support-mediated path.
Where this fits in the bigger picture
Licensing is one of the few mandatory cloud surfaces a self-hosted product has. Combined with private-by-default stance — no telemetry, opt-in cloud features, full data ownership — it stays small enough to be transparent. Six heartbeats a year, 600 bytes each, no content. That fits inside a privacy promise.
For the same engineering style applied to other non-obvious places (chat agents, home control, licensing's neighbour: payments via Stripe), see the rest of the blog.
What we're looking for
Edge cases on virtualisation. We have signal for ProxMox, Docker, and basic KVM, but the long tail of virt platforms is where the fingerprint stability argument is weakest. If you have a setup that breaks our fingerprint on a routine restart, that's exactly the case we want to fix.