Skip to content
← All posts
· Delta1 Labs KeyrightLicensing.NET

Licensing a .NET component library: the developer license, the key that compiles into your customer's app, and what the end user never sees

A component library has two audiences and only one of them is your customer. The developer who buys your PDF engine or charting control holds the license; the end users who run the apps built with it have never heard of you, must never be asked for a key, and run on machines you cannot activate. That changes where a license is checked and what travels inside the shipped binary. This post builds the design most control vendors converge on with Keyright: a perpetual, non-node-locked developer license verified at build time by an MSBuild target in your NuGet package, embedded into the customer's assembly as a signed attribute, and verified again at runtime offline against the public key in your library — with an updates-until entitlement instead of an expiry so the customer's app never stops, an audience claim so a license lifted from one app does not light up another, and a watermark-grade licensee name signed into every binary that uses it.

Most licensing advice assumes the person who runs your code is the person who paid for it. A component library breaks that assumption in half. You sell a PDF engine, a charting control, a barcode SDK, to a developer. The developer compiles it into an application and ships that application to end users — people who have never heard of you, cannot be asked for a key, and run on machines you will never activate. Your customer is the developer. Your code runs everywhere else.

That split changes two things: where the license is checked, and what has to travel inside the binary your customer ships. This post builds the design most control vendors converge on — a developer license verified at build time, embedded into the customer’s assembly, and verified again offline at runtime — with Keyright, and goes through the three places the obvious version of it goes wrong.

Two parties, two checks

Where the license is checked, and what travelsYou (vendor)issue developer licenseperpetual · no machine bindpublic key ships in libraryDeveloper / CI (customer)build target validatesembeds signed doc as attributeseat lease cached here onlyEnd user (not customer)library reads attributeverifies offline, no statenever asked for anythinglicenseapp.exeWhat crosses each boundaryvendor → developer: the signed license document (public data, protected by its signature)developer → end user: the same document, inside an assembly attribute, plus the library's public keynever: the activation lease, the developer's machine id, anything time- or machine-bound

The vendor side is familiar: you issue a license, the developer activates it, you count seats. The unfamiliar part is the second hop. Whatever your library needs at runtime to decide “licensed or evaluation” has to be inside the customer’s application, placed there at build time, and verifiable on a machine with no license file, no environment variable, no network, and no idea who you are. The license document itself is the right thing to put there — it is a signed payload, not a secret — but only if it is issued in a shape that survives the trip. That shape is the first thing to get right.

Issue the developer license in a shape that can travel

A Keyright license is a small signed JSON document: licensee, tier, expiry, product, entitlements, signature. For a desktop app you would typically issue it node-locked and, for a subscription, expiring. For a component library both of those are wrong, because the document is going to be copied into an app that runs on machines the license knows nothing about.

Issue it like this instead:

{
  "licensee": "Contoso Ltd",
  "expiryUtc": null,
  "tier": "Pro",
  "trial": false,
  "product": "acme-pdf",
  "licenseId": "lic_8f3a2c71",
  "entitlements": {
    "redistribute":  "true",
    "developers":    "5",
    "updates-until": "20271015",
    "audience":      "Contoso.Reports;Contoso.Reports.Desktop"
  },
  "signature": "…"
}

Four decisions are encoded there:

  • expiryUtc is null. The license is perpetual. If you sold “one year of updates,” that is not an expiry — an expiry would switch off every app Contoso has shipped the day their maintenance lapses. The maintenance window is the updates-until entitlement, and the library compares it against its own release date, not the wall clock. A 2027 build of your library checks that updates-until ≥ its build date; a 2028 build refuses the same license at build time, in the developer’s face, which is where that message belongs. (Keyright’s release coverage formalizes exactly this for applications; for a library an entitlement the library reads itself is the simplest thing that works everywhere.)
  • No machineId. The document is not node-locked; it has to validate on every end user’s machine. Developer seats are enforced on the developer’s side through activation — each developer activates the key, the issuing service counts developers seats, and the signed lease the SDK caches is what is bound to their machine. The lease never ships.
  • redistribute is an explicit flag. The same product may also sell an internal-use license to a team building line-of-business apps for its own staff; that one gets redistribute: false and the build target refuses to embed it. Making the flag explicit beats inferring it from tier.
  • audience lists the assemblies the license was sold for. This is the anti-copy measure, covered below.

Check it at build time, from inside your NuGet package

The developer’s build is where you can still talk to your customer. Your NuGet package ships a build/Acme.Pdf.targets that MSBuild imports automatically into any project referencing it; the target runs before CoreCompile, validates the developer’s license, and generates the attribute the runtime will look for.

<!-- build/Acme.Pdf.targets — imported automatically by the PackageReference -->
<Project>
  <PropertyGroup>
    <!-- Optional explicit path; otherwise ACME_PDF_LICENSE env var, then the default app-data path. -->
    <AcmePdfLicense Condition="'$(AcmePdfLicense)' == ''"></AcmePdfLicense>
    <!-- Set true on CI to turn 'no valid license' into a build error instead of an evaluation build. -->
    <AcmePdfRequireLicense Condition="'$(AcmePdfRequireLicense)' == ''">false</AcmePdfRequireLicense>
    <_AcmePdfLicenseFile>$(IntermediateOutputPath)AcmePdfLicense.g.cs</_AcmePdfLicenseFile>
  </PropertyGroup>

  <UsingTask TaskName="Acme.Pdf.Build.EmbedLicense"
             AssemblyFile="$(MSBuildThisFileDirectory)..\tools\Acme.Pdf.Build.dll" />

  <Target Name="AcmePdfEmbedLicense" BeforeTargets="CoreCompile"
          Inputs="$(MSBuildProjectFullPath)" Outputs="$(_AcmePdfLicenseFile)">
    <EmbedLicense LicensePath="$(AcmePdfLicense)"
                  AssemblyName="$(AssemblyName)"
                  RequireLicense="$(AcmePdfRequireLicense)"
                  OutputFile="$(_AcmePdfLicenseFile)" />
    <ItemGroup>
      <Compile Include="$(_AcmePdfLicenseFile)" />
      <FileWrites Include="$(_AcmePdfLicenseFile)" />
    </ItemGroup>
  </Target>
</Project>

The task itself is a thin wrapper around the Keyright SDK. It resolves the license through the SDK’s normal precedence — an explicit path wins, then an environment variable, then the per-user default — validates it offline, applies the library-specific rules, and writes the generated source:

public sealed class EmbedLicense : Microsoft.Build.Utilities.Task
{
    public string? LicensePath { get; set; }
    [Required] public string AssemblyName { get; set; } = "";
    public bool RequireLicense { get; set; }
    [Required] public string OutputFile { get; set; } = "";

    // The release date of THIS SDK build, stamped at pack time. Compared against updates-until.
    const long SdkReleaseDate = 20261015;

    public override bool Execute()
    {
        var client = KeyrightClient.Initialize(new KeyrightOptions
        {
            Product         = "acme-pdf",
            PublicKeyBase64 = AcmePublicKey.Base64,            // public key only; safe in a NuGet
            LicenseFilePath = string.IsNullOrEmpty(LicensePath) ? null : LicensePath,
            EnvVarName      = "ACME_PDF_LICENSE",              // not the shared KEYRIGHT_LICENSE
        });

        LicenseInfo info = client.Validate(out LicenseSourceKind source);   // offline; never throws
        string? problem = Diagnose(info);

        if (problem is not null)
        {
            if (RequireLicense) { Log.LogError("ACMEPDF001: {0}", problem); return false; }
            Log.LogWarning("ACMEPDF002: {0} Building in evaluation mode (output is watermarked).", problem);
            File.WriteAllText(OutputFile, "// Acme.Pdf: no license embedded (evaluation build)\n");
            return true;
        }

        // Embed the signed document verbatim. It is public data; the signature is the protection.
        string json = File.ReadAllText(ResolvedPath(client, source));   // the file `source` resolved to
        string b64  = Convert.ToBase64String(Encoding.UTF8.GetBytes(json));
        File.WriteAllText(OutputFile,
            $"[assembly: Acme.Pdf.Licensing.AcmePdfLicense(\"{b64}\")]\n");
        Log.LogMessage(MessageImportance.High,
            "Acme.Pdf: licensed to {0} ({1}); embedded into {2}.", info.Licensee, info.LicenseId, AssemblyName);
        return true;
    }

    string? Diagnose(LicenseInfo info)
    {
        if (!info.IsValid)                     return $"No valid Acme.Pdf developer license ({info.Status}: {info.Message}).";
        if (info.IsTrial)                      return "Trial licenses cannot be embedded in a redistributable build.";
        if (info.ExpiryUtc.HasValue)           return "Only perpetual developer licenses can be embedded; this one expires.";
        if (!info.Entitlements.IsEnabled("redistribute"))
                                               return "This license does not include redistribution rights.";
        if (info.Entitlements.GetLimit("updates-until") < SdkReleaseDate)
                                               return $"This license covers updates until {info.Entitlements.GetRaw("updates-until")}; this SDK build is newer.";
        var audience = (info.Entitlements.GetRaw("audience") ?? "").Split(';', StringSplitOptions.RemoveEmptyEntries);
        if (audience.Length > 0 && !audience.Contains(AssemblyName, StringComparer.OrdinalIgnoreCase))
                                               return $"This license is issued for {string.Join(", ", audience)}, not '{AssemblyName}'.";
        return null;
    }
}

Three details carry weight. EnvVarName is set to a product-specific variable rather than the SDK’s default KEYRIGHT_LICENSE, because a build agent may hold licenses for several Keyright-licensed products and you do not want yours resolved from someone else’s file. Validate(out source) tells the task which source won, which is what makes “it works on my machine but not in the pipeline” a one-line diagnosis — the pattern is the same one a headless CLI uses. And Diagnose refuses, at build time, every shape of license that would fail later on an end user’s machine: trials, anything with an expiry, anything without redistribution rights, anything the SDK build is too new for. Every one of those is a message the developer can act on. None of them is a message you can ever deliver to an end user.

What gets embedded, and why it is safe

The generated file is one line:

[assembly: Acme.Pdf.Licensing.AcmePdfLicense("eyJsaWNlbnNlZSI6ICJDb250b3NvIEx0ZCIsIC…")]

That string is the developer’s signed license document, base64-encoded, sitting in the customer’s assembly metadata where anyone with a decompiler can read it. That is fine, and it is worth being precise about why. A Keyright license is protected by its signature, not by secrecy — the SDK re-verifies the signature with the public key compiled into your library on every validation. Nothing in the document lets anyone issue another license, and editing a byte of it invalidates the signature. The licensee name and license id being readable in the binary is not a weakness; it is a watermark you get for free, and it is what lets you answer “whose license is in this cracked app?” from the binary alone.

The attribute type lives in your library, is trivial, and must survive obfuscation by name — the runtime looks it up via reflection:

namespace Acme.Pdf.Licensing;

[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false)]
public sealed class AcmePdfLicenseAttribute : Attribute
{
    public AcmePdfLicenseAttribute(string licenseBase64) => LicenseBase64 = licenseBase64;
    public string LicenseBase64 { get; }
}

The runtime check: find the attribute, verify offline, write nothing

Inside the library, the gate runs once, lazily, the first time something licensed is used. It has to find the attribute on the assembly that compiled against you, which is not always the entry assembly — your control may be inside a plugin, a class library, or a test host — so it scans loaded assemblies rather than assuming:

internal static class LicenseGate
{
    static readonly Lazy<LicenseInfo> Current = new(Resolve, LazyThreadSafetyMode.ExecutionAndPublication);

    public static bool IsLicensed => Current.Value.IsValid;
    public static string Licensee  => Current.Value.Licensee;

    static LicenseInfo Resolve()
    {
        // Find the first assembly carrying our attribute: usually the entry assembly, but a
        // plugin host or test runner may put the licensed consumer further down the list.
        var carrier = AppDomain.CurrentDomain.GetAssemblies()
            .Select(a => (asm: a, attr: a.GetCustomAttribute<AcmePdfLicenseAttribute>()))
            .FirstOrDefault(t => t.attr is not null);

        if (carrier.attr is null)
            return LicenseInfo.Free(LicenseStatus.NoLicense, "No Acme.Pdf license embedded in the host application.");

        string json = Encoding.UTF8.GetString(Convert.FromBase64String(carrier.attr.LicenseBase64));

        var client = KeyrightClient.Initialize(new KeyrightOptions
        {
            Product         = "acme-pdf",
            PublicKeyBase64 = AcmePublicKey.Base64,  // same public key the build task used
            LicenseString   = json,                  // authoritative: no env var, no file, no lease lookup
            EnvVarName      = "",                    // belt and braces: never consult the end user's environment
        });

        LicenseInfo info = client.Validate();        // signature + product + entitlements, offline, never throws
        if (!info.IsValid) return info;

        // The same rules the build enforced, re-checked where the code actually runs.
        if (!info.Entitlements.IsEnabled("redistribute"))
            return LicenseInfo.Free(LicenseStatus.NoLicense, "License does not include redistribution.");
        if (info.Entitlements.GetLimit("updates-until") < BuildStamp.SdkReleaseDate)
            return LicenseInfo.Free(LicenseStatus.Expired, "License does not cover this Acme.Pdf release.");

        var audience = (info.Entitlements.GetRaw("audience") ?? "").Split(';', StringSplitOptions.RemoveEmptyEntries);
        if (audience.Length > 0 && !audience.Contains(carrier.asm.GetName().Name, StringComparer.OrdinalIgnoreCase))
            return LicenseInfo.Free(LicenseStatus.NoLicense, "License audience does not match the host assembly.");

        return info;
    }
}

LicenseString is the detail that makes this correct. In the Keyright SDK an explicit license string is authoritative: when it is set, the client validates that document and nothing else — it does not look at environment variables, config paths, the default app-data location, or a cached lease. On an end user’s machine every one of those would be wrong to consult; there is no file there, and if by coincidence there were one (the end user is also a developer with some other Keyright-licensed product) it must not be picked up. The explicit string short-circuits all of it.

And because the embedded license is perpetual, the SDK writes nothing to the end user’s disk. Keyright keeps local state — first-seen and last-seen timestamps for trial duration and clock-rollback detection — only for time-bound licenses. A perpetual document is validated purely from its signature and content, so there is no state.json, no lease cache, no folder under the user’s app data with your product’s name on it. That is the right footprint for code running inside someone else’s application.

What the library does with IsLicensed == false is a product decision, and for a component the answer is almost always evaluation mode rather than refusal: render the chart with a diagonal “Evaluation” watermark, stamp the PDF footer, cap the barcode count. A developer evaluating you should see your library work. An end user whose vendor shipped an unlicensed build should see something that makes the vendor fix it — which a watermark does, and an exception at startup does too, but with their customers rather than yours.

The three ways the obvious design fails

Embedded…What the end user seesFixsubscription expiryapp stops the day the developer'smaintenance lapsesperpetual + updates-until entitlement,compared to the SDK release datenode-locked licenseMachineMismatch on every PCthat is not the developer'sissue unbound; seats counted byactivation leases that never shiplicense copied fromanother vendor's appa stranger's app runs fullylicensed under your customer's nameaudience entitlement at build + runtime;licensee name is the watermark

The expiring license. The most common mistake is to issue developer licenses exactly like app licenses — a twelve-month subscription with expiryUtc set — and embed them. It works perfectly for a year. Then the developer’s renewal lapses, or just lands late, and every application they have ever shipped drops into evaluation mode on their customers’ machines simultaneously. The build task above refuses to embed anything with an expiry for exactly this reason; the maintenance window lives in updates-until and is enforced against the library’s release date, which does not move once shipped.

The node-locked license. Equally natural, equally wrong: bind the developer license to the developer’s machine so it cannot be shared. Embedded, it fails with MachineMismatch on every end user’s PC. The seat problem is real, but it is solved on the other side of the boundary — the developer activates, the issuing service counts seats against developers, and the machine-bound artifact is the lease the SDK caches locally, never the license document. If you need the extra assurance, the build task can require a valid lease on the developer’s machine (source == LicenseSourceKind.Lease) while still embedding the unbound document.

The lifted license. The document is readable in the shipped binary, so a developer who did not pay can pull it out of a competitor’s app and paste it into their own. The audience entitlement is the answer: at issue time you record the assembly names the license was sold for, the build task refuses to embed into any other, and the runtime checks the carrier assembly’s name against the list. Be honest about what this buys. An attacker can rename their assembly Contoso.Reports and pass. But they now have to know to do that, their product ships under a stranger’s assembly name, and their binary carries Contoso’s licensee name and license id, signed, for you to find — this is the kind of evidence a licensing dispute is won with. For the patch-the-check attack, the gate above is ordinary C# that Nebula can protect: autoProtectLicensing finds the methods that call the Keyright SDK and gives them the strongest transforms the edition applies, so the IsLicensed branch is not a single brfalse to flip. Protecting the license check covers that side.

When the deployment is the thing you license

Everything above is the per-developer, royalty-free runtime model — the one that fits controls, SDKs and engines sold to developers building desktop and mobile apps. There is a second model for libraries that run on servers: you license the deployment, not the developer, because a single developer may stand up a thousand instances and the value scales with instances.

That model drops the embedding entirely. The host application activates with its own key at startup — ActivateAsync with the host’s product namespace — and your SDK’s entitlement rides inside the host’s license as a flag (acme-pdf: true) or a limit (acme-pdf-pages-per-day: 50000). Your library then asks the host’s resolved LicenseInfo rather than carrying its own; licensing containers and autoscaled services is the pattern. The two models are not exclusive. A library sold both ways issues two product namespaces, embeds for the desktop SKU, and reads the host’s entitlement for the server SKU; the runtime gate simply checks the embedded attribute first and the host license second.

The shape, in one paragraph

A component library is licensed to someone who is not there when it runs. So check the license where the customer is — in their build, from your NuGet package, with messages a developer can act on — and put the signed document into the binary they ship, in a shape that survives the trip: perpetual, unbound, with the maintenance window as an entitlement the library compares to its own release date and an audience the license was sold for. At runtime, verify that document offline with the public key you compiled in, write nothing to the end user’s disk, and degrade to an evaluation mode that embarrasses the vendor rather than punishing their customer. The lease stays home. The license travels. The end user never knows you exist, which is exactly right.

Try Nebula.NET

Harden your .NET code in minutes — start with the free edition.