A recurring piece of advice for licensing a desktop application goes roughly like this:
For a local offline desktop app, the simplest solid approach is: your licensing server creates a JWT. Sign it with a private key that never ships with the app. Embed only the public key inside your app. The app validates the JWT completely offline. Put license properties directly in JWT claims.
Every sentence of that is right. It is also, more or less, what a RockyGuard licence is at its core: a signed set of claims, verified against an embedded public key, with no network call. If you stopped reading here and shipped a JWT, you would be ahead of most homegrown schemes we have been asked to look at.
So this is not a post about why the advice is wrong. It is a post about the list of things the advice does not mention, because the people giving it have usually not had to support the result for three years. Some of those things you can add to a JWT in an afternoon. Some of them are the entire product.
What the two approaches share
Strip both down and they are the same shape:
| JWT (RS256 / EdDSA) | RockyGuard licence | |
|---|---|---|
| Payload | JSON claims | JSON fields |
| Signature | Asymmetric, private key stays with the vendor | Ed25519 or RSA, private key stays with the vendor |
| Verification | Offline, against an embedded public key | Offline, against an embedded public key |
| Expiry | exp claim, compared to the system clock | expires_at, compared to the system clock |
| Tooling | Every language has a library | CLI tools plus a C++ library |
Nothing in the right column is cryptographically stronger than the left. If someone tells you a commercial licence format has a better signature than a JWT signed with Ed25519, they are selling something. The differences are all in the rows that are not in this table.
1. The alg header is a decision the token should not get to make
A JWT carries its own algorithm in the header. The verifier reads alg and
dispatches accordingly. That design has produced a small family of well-known
failures: alg: none accepted as “no signature required”; a token signed
HS256 with the public key as the HMAC secret, verified by a library that
happily accepted it because the caller passed the public key as “the key”.
Modern libraries have closed these, but only if you configure them to pin the
algorithm, and the default in several is still to trust the header.
A licence verifier should never let the file it is verifying choose how it is verified. In RockyGuard the algorithm is derived from the embedded public key’s type at load time, never from the licence. There is no field an attacker can set to downgrade the check. This is one line of configuration in a good JWT library, and one line that is easy to leave out.
2. “Embed only the public key” is where the interesting attack lives
The advice is right that the public key is not secret. What it does not say
is that a public key embedded as a plain PEM string is a target anyway, not
for reading but for overwriting. One strings command finds it, and an
attacker who replaces those bytes with their own public key now has an
application that faithfully verifies licences they signed. Every JWT library
in the world will report the signature as valid, because it is.
We wrote about this attack, and about making the substitution fail closed, in
a separate post. The
short version: the key is reconstructed at runtime from two independently
masked tables that must agree, and the binary that carries them is itself
signed and checked. A JWT library gives you verify(token, public_key). Where
public_key comes from, and whether it is still yours, is your problem.
3. exp is only as honest as the clock
An offline application that enforces expiry is trusting the system clock,
and the system clock belongs to the person you are trying to bill. Setting it
back is a right-click. A JWT has no opinion about this; exp is a number and
the library compares it to now().
A licence library has to have an opinion, and the opinion turns out to be a surprising amount of code. RockyGuard writes time anchors, small HMAC-protected records of the latest moment the installation has observed, to several locations, and cross-checks them against the clock on every verification. A clock that has moved backwards past tolerance is an unambiguous rollback finding. A clock that is merely ahead of an anchor is ambiguous, because a stale anchor left behind by a previous install looks identical to a clock that has been wound back, and the two need different handling.
Getting that machinery right took us several releases. One of our own postmortems is about an HMAC salt that depended on how the anchor’s path was spelled, which denied valid licences to customers who had done nothing wrong. Another describes the three-days-fast clock that made every replacement licence read as expired the moment it was installed. Neither of those is a problem a JWT library will ever have, because a JWT library does not try to solve it.
4. Which machine?
The obvious way to node-lock a JWT is to put a hardware identifier in a claim and compare it at runtime. The question is which identifier, and what happens when it changes.
A single MAC address changes when the user docks a laptop. A disk serial changes when the SSD is replaced under warranty. Any one identifier makes the licence brittle; requiring all of them makes it more brittle still. RockyGuard hashes four components separately (MAC, CPU, disk, motherboard) and the licence carries a threshold: how many of them must still match. The default is two of four, which survives a docking station and a disk replacement but not a wholesale copy to a different machine.
Then there is the long tail. This week we shipped a fix for firmware that
reports its motherboard serial as To be filled by O.E.M. or Default string.
Hashed as an identity, that string made every board with the same lazy
firmware default match every other one, and every VM clone from the same
template match its siblings. The collectors now treat those placeholders as
unreadable and fall through to the next source. None of this is deep
cryptography. All of it is the kind of thing you only learn from a support
ticket, and a JWT claim named hwid has no way to have learned it for you.
5. Revocation, honestly
Here is the part where the commercial product does not have a clever answer, and you should be suspicious of any that claims to.
An offline licence cannot be revoked, whether it is a JWT or anything else. There is no server to ask. The only real tools are short expiry with renewal, or an online check-in that the application tolerates missing for a bounded period. Both approaches have exactly the same options here. RockyGuard’s contribution is on the vendor side: a generation counter that meters how many end-user licences a vendor licence may mint, so a leaked signing setup has a ceiling. That limits damage; it does not revoke a file already in the field.
6. The verify function returns a boolean
This is the deepest difference, and it has nothing to do with the token format.
jwt.verify() returns true or false. Your code then does if (!valid) exit;.
That if compiles to a compare and a jump, and a jump is one byte away from
its opposite. An attacker with a debugger does not forge your JWT; they never
look at your JWT. They find the branch and patch it. Ed25519 protected the
token perfectly and it did not matter.
We have written about this from three directions: obfuscating the
branch so it is hard to find,
checking the running code so a patch is
detected, and, most recently, removing the branch
altogether by having the licence carry a key that
the premium feature needs in order to decrypt its own data. There is no if
to flip when the feature simply cannot run without a value only a valid licence
provides.
A JWT can carry that key in a claim. Nothing stops you. But the JWT library will not hand you the decryption, the data-binding tool, the CMake helper, or the lint that checks you did not leave a plaintext copy in the build. That is the difference between a format and a product.
7. Floating seats
A JWT says “this machine may run the software.” It has no vocabulary for “any five of your forty engineers may run it at once.” Concurrent licensing needs a server that hands out and reclaims seats, a heartbeat so a crashed client gives its seat back, a signed response so the client can trust the server, and a client that behaves sensibly when the network drops mid-session. RockyGuard ships that server and client. If you need floating seats, the JWT approach means writing them.
8. When it fails, what does the customer see?
Licensing code has a peculiar property: the only time anyone looks at it is
when it has just refused a paying customer. At that moment false is not
useful. Neither is signature invalid when the actual cause is a clock three
days fast, a licence issued for the wrong machine, or a .sig file that did
not get copied during an upgrade.
RockyGuard v1.4 returns a stable
diagnostic code for each distinct cause,
ships a collector the end user can run to produce a sealed report, and a
reader that correlates across that report to say “the licence has not expired;
the clock is wrong.” Every one of those is a support hour you will otherwise
spend on the phone. A JWT library’s error is JsonWebTokenError: invalid signature.
What we would actually tell you
If your product is a small tool, your customers are broadly honest, and a pirated copy costs you a lunch rather than a quarter, use the JWT. Pin the algorithm, embed the key, put your claims in it, and spend the saved time on the product. You do not need us, and we would rather say so than sell you something you will resent.
If any of the following is true, the JWT is where your licensing begins and you are about to write the rest of this post yourself:
- a single leaked licence file costs you real revenue
- you need to bind to a machine and survive a hardware change
- you enforce expiry and your customers control their own clocks
- you sell concurrent seats
- you have a support desk, and its time is not free
None of that requires RockyGuard specifically. It requires something that has already made the mistakes above and written down what it learned. Our version of that is a C++17 library, a set of command-line tools, and the postmortems from getting there, several of which are linked from this page.
The token-shaped core of a RockyGuard licence, and how to embed the key, is Customer Documentation §4 and §9.9. The quick start goes from a fresh keypair to a verified licence in about ten minutes, which is a fair way to judge whether the parts around the signature are worth it to you.