Skip to main content
Migrate Already selling? Move your customers to Keylight without re-issuing a single key.
Keylight
Blog
dotnet csharp license-keys offline

License Keys for .NET Apps: Desktop and Server with Keylight

9 min read Nicolas Demanez — Founder

Most .NET license key implementations I have seen start the same way. Someone writes a CheckLicense() method that hashes the key against a secret, ships it, and moves on. Six months later that method is the most expensive code in the product. It cannot revoke a refunded key, it cannot count devices, and it cannot tell the difference between a customer who paid and one who pasted a key from a forum.

The fix is to stop treating licensing as a function and treat it as a service your app talks to. For .NET that is one NuGet package on the desktop side and one HTTPS call on the server side. Keylight handles the keys, the seats, the revocations, and the trial policy. Your code asks a question and gates a feature.

This post covers both seats: the Windows desktop app, and the .NET team that needs a server-side check for a product that lives partly on their own infrastructure. If you are shipping a Unity game, the same package powers it, but the install path is different. Go to the Unity post instead.

One package, zero dependencies

Install it:

dotnet add package Keylight

The package targets netstandard2.0 and net8.0. There are no PackageReference entries in the project, which means no transitive surprises and nothing that varies across runtimes. It runs on .NET Framework 4.6.1 and later, .NET Core 2.0 and later, Mono, and Godot 4. The pure-managed design is deliberate: the same code has to survive IL2CPP and WebGL in Unity, so there is no native crypto library to load and no platform-specific path to debug.

The client is built with a builder, then constructed once and kept for the life of the process:

using Keylight;

var config = KeylightConfig
    .Builder("your-tenant", "your-product", "sdk_live_...")
    .TrustedKeys(new Dictionary<string, string>
    {
        ["k1"] = "<public key from your dashboard>"
    })
    .TrialDurationDays(14)   // seed only; the dashboard value wins once the app has been online
    .MaxOfflineDays(15)
    .Build();

var client = new KeylightClient(config);

TrustedKeys is the set of public keys your app uses to verify what the server sends back. Pin them at build time. There is a Keyset.FetchAsync helper that pulls the current keyset from the API, and it is fine for bootstrapping a dev build, but it is not a trust root. If the app fetched the keys over the same channel it fetches the license from, anyone who could forge one could forge the other.

The .NET license key flow on the desktop

The desktop flow has three moments: launch, first activation, and gating a feature. It is the same shape whether the UI is WPF, WinUI, or MAUI.

On launch, call CheckOnLaunchAsync. It reads the stored lease, verifies it locally, refreshes with the server if a refresh is due, and starts the trial clock if there is no license yet. If the device is offline it does not throw. It resolves state from what it has on disk.

await client.CheckOnLaunchAsync();

switch (client.State)
{
    case KeylightState.Licensed:
    case KeylightState.Trial:
        ShowMainWindow();
        break;
    case KeylightState.Expired:
    case KeylightState.Invalid:
        ShowActivationWindow();
        break;
}

When the customer pastes a key, activate it once. ActivateAsync talks to the server, binds this device as a seat on the license, and stores the signed lease. It throws ActivationException on a rejected key, so the activation window can show the reason.

try
{
    await client.ActivateAsync(licenseKeyTextBox.Text.Trim());
    ShowMainWindow();
}
catch (ActivationException ex)
{
    errorLabel.Text = ex.Message;   // wrong key, seat limit reached, revoked
}

Gating a feature is a synchronous read from the verified lease. No network, safe to call from the UI thread, safe to call on every click.

exportPdfButton.IsEnabled = client.HasEntitlement("pro");

Note what is not there. There is no Tier property and no list of entitlements to iterate. You ask for the feature you care about by name, and the answer comes from the lease the server signed. Define the entitlement names in the dashboard, attach them to your license types, and the same string works in every SDK.

Between launches, RefreshIfNeededAsync runs a debounced refresh that picks up renewals, revocations, and settings changes. Call it on a timer or on window focus. It never throws on a network failure.

Keylight also handles the moment a customer upgrades. RefreshAfterUpgradeAsync polls the server for up to 30 seconds after your app returns from the upgrade page, and resolves true the instant the entitlement set changes. The paid feature appears without a restart and without a second key. If nothing changed in the window it returns false, and the normal refresh cadence catches it later.

The trial length lives in the dashboard now

This is the part that changed in the last release and the part I want people to notice. The trial length and the free-tier flag are the server’s, not the build’s.

Two calls expose them:

int trialDays = client.EffectiveTrialDurationDays();   // server value, then your seed, then 0
bool freeTier = client.EffectiveFreeTierEnabled();     // server value, then false

The TrialDurationDays(14) you set in the builder is a seed for the very first launch, before the app has ever reached the server. After that, the value from your dashboard wins. Change 14 days to 7 from a dropdown and every install picks it up on its next launch. No release, no app store review, no “please update to get the new trial”.

The settings arrive on two routes. They ride along on every validate response, so a licensed app never pays an extra round trip. And when there is no active license, CheckOnLaunchAsync calls FetchConfigAsync to read them from /config, because a trial user has no other way to hear from the server. Both routes merge at one point in the SDK, and both respect the rule that a real 0 from the server is a real setting, not a missing one.

Signed settings are the opt-in layer on top. With RequireSignedConfig(true) in the builder, a settings response has to carry a signature from one of your pinned keys or it is dropped and the app keeps its seed. That closes the edit-the-cache trick where someone stretches their own trial. It is off by default, and it should stay off until your product has a trial length configured in the dashboard, because the server only signs settings for products that do. Turn it on before launch, not after the first leak.

Validating keys from ASP.NET or a worker

Here is where I need to be precise, because the obvious approach is wrong.

The C# SDK is a per-device client. It activates one seat, stores one lease in a file, and answers questions about that one license. It is exactly right inside a desktop app. It is the wrong tool for a server that needs to check keys belonging to many different customers, because it cannot validate an arbitrary key without activating it on that machine.

For that job, use the Keylight runtime API directly. It is plain HTTPS and clean JSON, and it works from any language, which is the point. Mint a token first:

keylight init --mode api

That writes KEYLIGHT_API_TOKEN to your .env with owner-only permissions. The token has one scope, licenses:runtime. It can activate, validate, and deactivate devices, and nothing else. It cannot mint keys, revoke keys, read customers, or touch billing. It is still a server-side secret. Treat it like a database password.

Then validate from HttpClient:

using System.Net.Http.Json;

var http = new HttpClient { BaseAddress = new Uri("https://api.keylight.dev/") };
http.DefaultRequestHeaders.Authorization =
    new("Bearer", Environment.GetEnvironmentVariable("KEYLIGHT_API_TOKEN"));

var response = await http.PostAsJsonAsync("v1/licenses/validate", new
{
    product_id = "your-product",
    license_key = customerKey,
    device_id = "api-server-1"   // a stable handle you choose
});

var result = await response.Content.ReadFromJsonAsync<ValidateResult>();

if (result is { Valid: true } && result.Entitlements.Contains("pro"))
{
    // serve the paid path
}

record ValidateResult(
    bool Valid, string Status, long? Expires_At,
    string[] Entitlements, string Device_Id,
    long Revalidate_After, string? Reason);

A rejected key comes back as a normal 200 with valid: false and a reason. That is intentional. A uniform response body means nobody can use your server to enumerate which keys exist.

Two rules for the result. First, every validate is a live read of the server’s state, so a revoked or expired key is caught on the next call. Second, cache the result until revalidate_after and not one second longer or shorter. That timestamp is a stable interval the server picks per license. Do not invent your own polling schedule to be safe. The server owns expiry and entitlements, and it tells you when to ask again. For refunds, subscribe to the license.refunded webhook rather than polling tighter.

Trial length and the free tier do not reach this API. In API mode your own server owns the pre-purchase policy. If you want the full walkthrough with curl and a second language, it is in the API path post.

There is one server-side case where the SDK does fit: a hosted worker that is itself the licensed thing. A render node, an on-prem agent, a service a customer installs on their own box. That worker holds one license and one seat, so the per-device client is exactly right:

public sealed class LicenseGuard(KeylightClient client) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        await client.CheckOnLaunchAsync(ct);
        while (!ct.IsCancellationRequested)
        {
            await client.RefreshIfNeededAsync(ct);
            Worker.Paused = client.State is KeylightState.Expired or KeylightState.Invalid;
            await Task.Delay(TimeSpan.FromMinutes(5), ct);
        }
    }
}

Register the client as a singleton and the guard as a hosted service. The worker keeps running through a network blip, pauses when the license lapses, and resumes on the next good refresh.

What happens when the customer stops paying

Whatever you do, do not hard-lock. A lapsed key should drop the app to its free floor, keep the customer’s files reachable, and leave the door open. I wrote the full argument in Free Is the Floor, and the short version is that a customer who can still open their work is a customer who can still come back.

In the .NET SDK today that means checking State and HasEntitlement together. Expired tells you the license lapsed. EffectiveFreeTierEnabled() tells you whether the dashboard says a free tier exists. What the free tier unlocks is your call, gated by the same HasEntitlement checks you already wrote. The SDK reports the flag and stays out of the product decision.

Offline behaviour follows the same principle. After activation the app runs from the signed lease for up to MaxOfflineDays, 15 by default, with no network at all. A copied lease dies on a second machine because it is bound to the device that activated it. A rolled-back clock is caught. On the day the window closes, the app needs one successful check, and if the license is still valid it carries on as if nothing happened.

Where the C# SDK stands today

I would rather say this plainly than have you find out from the changelog.

The C# SDK carries an Alpha badge on the SDK overview. Here is what that badge does and does not mean.

Shipped and covered by tests, including the cross-SDK conformance vectors that every Keylight SDK has to agree with:

  • Activation, validation, and deactivation
  • Offline lease verification with the MaxOfflineDays bound and clock-rollback detection
  • Server-owned trial length and free-tier flag
  • Signed settings with pinned keys, opt-in
  • Post-upgrade refresh
  • Revocation enforced on launch and on active revalidation

Not in the C# SDK yet:

  • The keyless beacon, which is how the other SDKs count free users as devices in the dashboard
  • The FreeTier and Limited states. C# resolves to Licensed, Trial, Expired, or Invalid

If your product is a paid app with a trial, none of the gaps touch you. If you run a free tier and want those users to show up in the device dashboard, watch the changelog. The badge comes off when the beacon lands.

Pricing is the same for every SDK and the API path, and the free plan covers a real launch. The numbers are on the pricing page. The license model itself, lifetime and subscription keys, seat limits, and revocation, is described on the license keys page.

If something in this post does not match what you see in your project, send your feedback and I will fix the post or the SDK, whichever is wrong.

Nicolas Demanez, Founder

Frequently asked

Does the Keylight NuGet package work on .NET Framework?+

Yes. The package targets netstandard2.0 and net8.0, so it runs on .NET Framework 4.6.1 and later, .NET Core 2.0 and later, Mono, and Godot 4. It has zero runtime dependencies.

Can I validate a customer license key from an ASP.NET backend?+

Yes, over the Keylight runtime API. Mint a runtime token with keylight init --mode api, then POST the key to /v1/licenses/validate from HttpClient. The C# SDK itself is a per-device client and is not the tool for checking other people’s keys on your server.

Does a .NET app licensed with Keylight work offline?+

Yes. After activation the SDK stores a signed lease and verifies it locally on every launch. The offline window is bounded by MaxOfflineDays, which defaults to 15, after which the app needs one successful check with the server.

Is the C# SDK production ready?+

It carries an Alpha badge on the docs site. Activation, validation, offline verification, the server-owned trial length and free-tier flag, signed settings, and post-upgrade refresh are all shipped and tested. The keyless beacon and the FreeTier and Limited states are not in the C# SDK yet.

Ready to ship?

Create your account and start licensing your apps in under a minute. Free forever tier included.

Start Free