RockyGuard

Blog

A JWT is a fine licence file. Then the work starts.

·Rocky Software ·10 min read

guidelicensing-modelssecurity

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
PayloadJSON claimsJSON fields
SignatureAsymmetric, private key stays with the vendorEd25519 or RSA, private key stays with the vendor
VerificationOffline, against an embedded public keyOffline, against an embedded public key
Expiryexp claim, compared to the system clockexpires_at, compared to the system clock
ToolingEvery language has a libraryCLI 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.