Keylight for C++: One Header, No Backend, Signed Settings
If you ship native software, you have probably looked for a C++ license key library and closed the tab. Most of them want a build system fight, a crypto dependency, a licensing server, or all three. I ship C++ code too, and I did not want any of that in a codebase I have to keep compiling for years.
So the Keylight C++ SDK is one header. You drop keylight_single.hpp into your project, include it, and you have license keys, offline checks, and settings you change from a dashboard instead of a rebuild. No server to run. No package manager required. This post is the whole integration, start to finish, at version 0.2.2.
The header-only promise
keylight_single.hpp sits at the root of the repo. It is a pre-generated amalgamation of the core headers, so a single include gives you the client, the lease verifier, the encrypted store, and the config types:
#include "keylight_single.hpp"
The core has no third-party dependencies. The signature verification, the hashing, the authenticated encryption for the on-disk store, and the JSON parser are all vendored in. You do not install OpenSSL for it. You do not install libcurl. You compile a C++17 translation unit and you are done.
Two things you do have to know.
First, the single header does not include an HTTP client. Networking is a Transport you provide. If your app already talks HTTP through Qt, JUCE, Boost.Beast, or the platform APIs, wrap that in a small Transport and the SDK will use it. If you would rather not write one, the repo ships an optional transport built on cpp-httplib. That one needs OpenSSL, and it is behind a CMake flag so nobody pays for it by accident: -DKEYLIGHT_BUILD_HTTPLIB_TRANSPORT=ON.
Second, the header reads a hardware identity so that a lease is bound to one machine. On macOS that touches IOKit and CoreFoundation, on Windows it touches advapi32, on Linux it needs nothing. Single-header users add that link line themselves:
# macOS
clang++ -std=c++17 main.cpp -framework IOKit -framework CoreFoundation
# Windows (MSVC)
cl /std:c++17 main.cpp advapi32.lib
# Linux
g++ -std=c++17 main.cpp
When to use the CMake target instead
If your project is already CMake and you want the link lines handled for you, pull the repo in with FetchContent and link the interface target:
include(FetchContent)
FetchContent_Declare(keylight
GIT_REPOSITORY https://github.com/keylight-dev/keylight-cpp.git
GIT_TAG v0.2.2)
FetchContent_MakeAvailable(keylight)
target_link_libraries(my_app PRIVATE keylight::keylight)
Same code, same header contents, just with the platform frameworks wired up by the target. Pick the single header when you want zero build-system changes. Pick the CMake target when you already live in CMake. There is no functional difference between the two.
Initialize, activate, validate
Everything goes through keylight::Config and keylight::Client. You fill a config struct by field, hand it a transport and a store, and call three methods.
#include "keylight_single.hpp"
keylight::Config cfg;
cfg.tenantId = "your-tenant";
cfg.productId = "your-product";
cfg.sdkKey = "sdk_live_..."; // from the dashboard
cfg.trustedKeys["k1"] = "<base64 public key>"; // your tenant's signing key
MyTransport transport; // your HTTP wrapper, or the optional httplib one
auto store = keylight::EncryptedFileStore::migrating(
keylight::default_store_path(cfg),
keylight::legacy_plaintext_path(cfg));
keylight::Client client(cfg, transport, store);
// On every launch: read the local lease, refresh if it is due, start the trial clock.
client.checkOnLaunch();
// When the customer pastes a key:
auto res = client.activate("USER-LICENSE-KEY");
if (res.is_ok() && res.value() == keylight::State::Licensed) {
// seat bound to this machine, signed lease on disk
}
// Gate a feature. Reads the verified lease, no network call.
if (client.hasEntitlement("pro")) {
enableProFeatures();
}
activate returns a Result<State>. On success the server has bound one seat of that key to this machine and handed back a signed lease, which the client stores encrypted and machine-bound. On failure you get an error code and a message you can show the customer.
validate is the online re-check. You usually do not call it by hand: checkOnLaunch and refreshIfNeeded call it on the normal cadence, and startAutoValidation runs it in the background every 30 minutes by default. Call validate directly when the customer hits a “Check license” button and you want a fresh answer right now.
Two accessors matter for the rest of your code. state() returns one of Licensed, Trial, Expired, Invalid, FreeTier, or Limited. It is noexcept and safe to poll from anywhere, including an audio thread. hasEntitlement("pro") reads the entitlement strings inside the verified lease and is guarded by a mutex, so keep it on a thread that is allowed to block.
There is no tier field. A license grants a set of entitlement strings, you name those in the dashboard, and your code checks for the ones it cares about. That is the whole model.
Signed settings with a pinned public key
Since 0.2.0 the trial length and the free tier are owned by your dashboard, not your binary. cfg.trialDurationDays is only a seed for the first launch before the app has ever reached the server. After that, effectiveTrialDurationDays() and effectiveFreeTierEnabled() return what the dashboard says. Change 14 days to 7 in a dropdown and every install picks it up on the next check. No rebuild, no release.
Those settings ride on the normal validate response and on the keyless beacon, so reading them costs no extra round trip. fetchConfig() exists for when you want an explicit refresh.
Now the part that matters for anyone who has watched a customer edit a config file. A setting the app reads from the network is a setting someone can try to forge. A proxy in the middle, or a patched cache on disk, that says “trial is 9999 days” or “free tier includes everything”. Signed config closes that. The server signs the settings payload with your tenant’s key, and the client checks that signature against the public key you compiled in.
It is an opt-in switch, and it is off by default:
cfg.trustedKeys["k1"] = "<base64 public key>"; // pinned at build time
cfg.requireSignedConfig = true; // default is false
keylight::Client client(cfg, transport, store);
auto r = client.fetchConfig(); // Result<void>; fails if the body is unsigned or forged
int days = client.effectiveTrialDurationDays();
bool free = client.effectiveFreeTierEnabled();
With the flag on, a settings body must carry a key id that is in trustedKeys and a signature that is valid inside its time window. Anything else is dropped and never cached. Your app keeps whatever it last verified, or the compiled-in seed if it never verified anything. It does not crash, and it does not trust the unsigned value “just this once”.
With the flag off, the signature fields are ignored and the settings are used as-is, the same as every other Keylight SDK at its default. That is on purpose. The server only signs settings for products that have a trial length configured in the dashboard, so turning the flag on for a product that is not set up yet would reject legitimate responses and freeze the app on its seed. Configure the trial in the dashboard first, then flip the switch, then ship. Do it before launch, not after the first leak.
Key rotation is handled the boring way. If you rotate to a new key id, builds still pinned to the old one keep their last verified settings. A freeze, not a failure.
Post-upgrade refresh without a restart
Your customer is on the basic tier. They open the portal, pay for the higher tier, and come back to the app. The old experience was “please quit and reopen” or “paste your new key”. Neither is acceptable in 2026.
refreshAfterUpgrade is a bounded poll. It snapshots the current entitlements and state, re-validates every two seconds, and returns true the moment either changes. Same key, same machine, higher tier, and the paid feature appears while the app is still open.
// Blocking form, fine for a worker thread. Default: 30 s timeout, 2 s poll.
if (client.refreshAfterUpgrade()) {
refreshUiForEntitlements();
}
// Non-blocking form for UI code.
std::future<bool> fut = client.refreshAfterUpgradeAsync();
Two honest details. It returns false immediately, with no network call, if there is no stored license on this machine. And it returns false on timeout, which is not an error: the normal validation cadence picks the upgrade up later, this call just makes it fast. It compares the entitlement set and the state, so an upgrade that only changes the seat count is not something it will notice.
If you want the customer’s upgrade page, upgradeUrl() hands you the portal link for this license. Open it, wait on the future, refresh the UI. That is the entire upgrade flow, and you did not write a checkout.
Offline, and the same answer in every language
After activation, the app holds a signed lease and checks it locally. No internet, the app still opens. The default window is maxOfflineDays = 15 since the last online validation, and you can shorten it or set it to 0 to trust the lease for as long as the lease itself is current. Verification allows 300 seconds of clock skew. A system clock rolled back by more than an hour from the last server contact makes state() report Invalid, and that guard runs at every state read, so the old trick of winding the clock back to extend a trial does not work. One caveat from the docs: checkTrial() and trialDaysLeft() do not apply the rollback guard, so gate on state(), not on those.
Revocation works the way you would expect and the way I am willing to promise. Revoke a refunded key in the dashboard and it is dead server-side that second. A machine holding a valid lease keeps running until its next online check, at most until the offline window closes. Nobody gets an instant remote kill, and anyone who tells you otherwise is describing a product that breaks on airplanes.
The verifier gives the same result as the Swift, Rust, JavaScript, and C# SDKs, checked against a shared set of conformance vectors in CI on Ubuntu, macOS, and Windows. A lease that is valid in one language is valid in all of them, and one that is expired is expired everywhere. That is the property that lets you ship the same product on several stacks without three different licensing bugs.
Where to go next
The header is the same whether you are building a plain CMake tool, a DAW plugin, or a game module, but the framework around it changes what you do with state() and where activation lives.
- Shipping a JUCE plugin? Offline Licensing for Your VST/AU Plugin Without a Backend covers activating from the editor and gating in the audio thread.
- Shipping an Unreal game? License Your Unreal Engine Game Offline in an Afternoon covers the plugin drop-in and Blueprint gating.
- Everything else, including the transport interface and the CMake options, is in the C++ SDK install docs.
Keys are minted and emailed the moment someone pays, from whichever payment tool you already use, and the license keys feature page covers the key types and device limits you set per product. There is a free plan on pricing that is enough to ship your first native app and find out whether anyone buys it.
If something in this post does not match what the header does on your machine, send your feedback and I will fix the post or the SDK, whichever is wrong.
Nicolas Demanez, Founder
Frequently asked
Does the Keylight C++ SDK need OpenSSL or libcurl?+
No. The core is one C++17 header with the crypto and JSON parser vendored in. OpenSSL and cpp-httplib are only pulled in if you opt into the bundled HTTP transport. Otherwise you plug in your own transport.
Does the app work when the customer is offline?+
Yes. After activation the app holds a signed lease and checks it locally. The default offline window is 15 days since the last online validation, and a clock rolled back by more than an hour is caught.
What is signed config and do I have to turn it on?+
Signed config means the trial length and free tier your app reads from the dashboard arrive with a signature your build can check against a pinned key. It is off by default. Turn it on before launch, once your product has a trial length set in the dashboard.
Which frameworks does the C++ SDK support?+
Any C++17 project. There are dedicated guides for JUCE audio plugins and Unreal Engine games, and the same header works in a plain CMake tool, a DAW plugin, or an engine module.
Ready to ship?
Create your account and start licensing your apps in under a minute. Free forever tier included.
Start Free