Documentation menu▾

.NET

Activate license keys in a WPF, WinForms or WinUI app, check a signed token offline with BouncyCastle and handle revoked licenses. .NET 8 and later.

This guide adds license keys to a Windows app built with .NET, such as WPF, WinForms or WinUI. The customer enters a key once, the app activates it against your Keygate server, and from then on it starts offline from a signed token. For the concepts behind each step, see Activating licenses in your app.

Dependencies

.NET has no built in Ed25519, which the tokens are signed with. BouncyCastle provides it in fully managed code, with nothing native to ship:

bash
dotnet add package BouncyCastle.Cryptography

The class below works on .NET 8 and later.

The license class

Fill in your server address and the public key from /api/v1/license/pubkey, and change MyApp to your app's folder name:

License.cs
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Win32;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.Crypto.Signers;

public record Claims(
    [property: JsonPropertyName("lid")] string LicenseId,
    [property: JsonPropertyName("did")] string Device,
    [property: JsonPropertyName("exp")] long CheckBy,
    [property: JsonPropertyName("ftr")] Dictionary<string, JsonElement>? Features);

public class LicenseException(int status, string code) : Exception(code)
{
    public int Status { get; } = status;
}

public static class License
{
    const string Server = "https://licenses.example.com";
    const string PublicKeyHex = "3b6a27bc..."; // from /api/v1/license/pubkey
    const int OfflineDays = 7; // how long to keep working when the server cannot be reached

    record Saved(string LicenseKey, string Token);

    static readonly HttpClient Http = new();
    static readonly string FilePath = Path.Combine(
        Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "MyApp", "license.json");
    static readonly string Identifier = MachineId();

    // The Windows machine ID, hashed with SHA256, so it is stable but not readable.
    static string MachineId()
    {
        var id = Registry.GetValue(
            @"HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Cryptography", "MachineGuid", null) as string;
        return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(id ?? ""))).ToLowerInvariant();
    }

    static byte[] FromBase64Url(string s)
    {
        s = s.Replace('-', '+').Replace('_', '/');
        return Convert.FromBase64String(s.PadRight(s.Length + (4 - s.Length % 4) % 4, '='));
    }

    static Claims? ReadToken(string token)
    {
        try
        {
            var parts = token.Split('.');
            var payload = Encoding.ASCII.GetBytes(parts[0]);
            var signer = new Ed25519Signer();
            signer.Init(false, new Ed25519PublicKeyParameters(Convert.FromHexString(PublicKeyHex)));
            signer.BlockUpdate(payload, 0, payload.Length);
            if (!signer.VerifySignature(FromBase64Url(parts[1]))) return null;

            var claims = JsonSerializer.Deserialize<Claims>(FromBase64Url(parts[0]));
            return claims?.Device == Identifier ? claims : null;
        }
        catch (Exception e) when (e is FormatException or JsonException or IndexOutOfRangeException)
        {
            return null;
        }
    }

    static async Task<string> Call(string action, string licenseKey)
    {
        var res = await Http.PostAsJsonAsync($"{Server}/api/v1/license/{action}", new
        {
            license_key = licenseKey,
            identifier = Identifier,
            label = Environment.MachineName,
        });
        var json = await res.Content.ReadFromJsonAsync<JsonElement>();
        if (!res.IsSuccessStatusCode)
        {
            throw new LicenseException((int)res.StatusCode, json.GetProperty("error").GetProperty("code").GetString()!);
        }
        return json.GetProperty("data").GetProperty("token").GetString()!;
    }

    static void Save(Saved saved)
    {
        Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!);
        File.WriteAllText(FilePath, JsonSerializer.Serialize(saved));
    }

    public static async Task<Claims> Activate(string licenseKey)
    {
        var token = await Call("activate", licenseKey);
        Save(new Saved(licenseKey, token));
        return ReadToken(token) ?? throw new LicenseException(0, "INVALID_TOKEN");
    }

    // Returns the license claims, or null when the app is not licensed.
    public static async Task<Claims?> Check()
    {
        if (!File.Exists(FilePath)) return null;
        var saved = JsonSerializer.Deserialize<Saved>(File.ReadAllText(FilePath))!;
        var claims = ReadToken(saved.Token);
        var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
        if (claims != null && claims.CheckBy > now) return claims;

        try
        {
            var token = await Call("verify", saved.LicenseKey);
            Save(saved with { Token = token });
            return ReadToken(token);
        }
        catch (LicenseException e) when (e.Status == 404)
        {
            File.Delete(FilePath);
            return null;
        }
        catch (HttpRequestException)
        {
            // No connection: trust the last good token for a few more days.
            return claims != null && claims.CheckBy + OfflineDays * 86400 > now ? claims : null;
        }
    }
}

Check works without a network connection as long as the saved token is fresh. When it is due, it asks the server again and saves the new token. If the server says the license no longer works on this device, the saved key is removed; if the server cannot be reached, the app keeps working for OfflineDays longer.

Using it

Check the license on startup, before showing the main window:

csharp
var license = await License.Check();
if (license is null)
{
    // Show your activation window. When the customer submits a key:
    try
    {
        license = await License.Activate(key);
    }
    catch (LicenseException e)
    {
        ShowError(e.Message); // for example ACTIVATION_LIMIT
    }
}

The exception message is the Keygate error code, and the error table lists what to tell the customer for each one. license.Features holds the plan's features, so the app can turn parts of itself on and off. See Features and usage limits.

Other platforms

Only MachineId is specific to Windows. For an Avalonia or MAUI app on macOS or Linux, read IOPlatformUUID or /etc/machine-id there instead and keep the rest of the class as it is.

Before you ship

  • Test with a real license from your dashboard, including what happens when you deactivate the device or revoke the license.
  • Keygate can serve updates for Velopack, so only licensed customers receive new versions. See Shipping updates.

Last updated October 4, 2026