Skip to content
← All posts
· Delta1 Labs Licensing.NETGuide

Per-Core Licensing for .NET Server Software: Counting Cores Correctly and Binding the Entitlement

Per-core licensing lives or dies on counting cores correctly. Here is why Environment.ProcessorCount misleads, how to read physical cores and container CPU quotas in .NET, and how to bind a core-count entitlement into a signed license.

Per-core licensing is how a lot of server software is sold: a price that scales with the CPU capacity the customer runs you on, because a database engine or analytics service on a 64-core box delivers far more value than the same binary on a 4-core VM. The model is simple to state and surprisingly easy to get wrong, because the hard part is not the policy — it is answering, correctly and tamper-resistantly, the question “how many cores is this actually running on?” In .NET the obvious API gives you the wrong answer more often than the right one.

The counting trap

Reach for Environment.ProcessorCount and you will ship a licensing bug. It returns the number of logical processors available to the current process — which is not the capacity you are selling, in at least three ways:

  • It counts hyperthreads, so an 8 physical-core chip typically reports 16.
  • It shrinks under affinity: if the process is pinned to a subset of CPUs (via affinity masks or a scheduler), it reports the subset, not the machine.
  • It ignores container quotas: in a Kubernetes pod limited to 2 CPUs on a 64-core node, it may still report a large number depending on runtime and version — you are not measuring the limit the orchestrator actually imposed.

So before you write a single check, decide which number is your licensing unit: physical cores, logical processors, or the container CPU quota. Then measure that one on purpose.

Three different "core counts" for the same host Physical host: 32 physical cores / 64 logical processors Environment.ProcessorCount here can report 64 (logical) — rarely what you mean to license. Container: cgroup cpu.max = "200000 100000" → effective limit = 2 cores (what to license in k8s/Docker) Affinity-pinned process ProcessorCount reports the pinned subset, not the box

Measuring the right number

Logical processors (cheap, but least meaningful on its own):

int logical = Environment.ProcessorCount;

Physical cores on Windows — sum NumberOfCores across sockets:

using System.Management; // Windows only
int physical = 0;
using var searcher = new ManagementObjectSearcher("SELECT NumberOfCores FROM Win32_Processor");
foreach (var mo in searcher.Get())
    physical += Convert.ToInt32(mo["NumberOfCores"]);

Physical cores on Linux — count unique (physical id, core id) pairs:

var pairs = new HashSet<(string socket, string core)>();
string socket = "0", core = "0";
foreach (var line in File.ReadLines("/proc/cpuinfo"))
{
    if (line.StartsWith("physical id")) socket = line.Split(':')[1].Trim();
    else if (line.StartsWith("core id")) core = line.Split(':')[1].Trim();
    else if (line.Length == 0) pairs.Add((socket, core));
}
int physicalLinux = Math.Max(pairs.Count, 1);

Container CPU quota — the number that actually matters in Kubernetes/Docker:

// cgroup v2: /sys/fs/cgroup/cpu.max  →  "<quota> <period>" or "max <period>"
static double? CgroupCpuLimit()
{
    const string v2 = "/sys/fs/cgroup/cpu.max";
    if (File.Exists(v2))
    {
        var parts = File.ReadAllText(v2).Split(' ', StringSplitOptions.RemoveEmptyEntries);
        if (parts[0] == "max") return null;                 // unlimited
        return double.Parse(parts[0]) / double.Parse(parts[1]);
    }
    // cgroup v1
    const string q = "/sys/fs/cgroup/cpu/cpu.cfs_quota_us";
    const string p = "/sys/fs/cgroup/cpu/cpu.cfs_period_us";
    if (File.Exists(q) && File.Exists(p))
    {
        double quota = double.Parse(File.ReadAllText(q).Trim());
        if (quota <= 0) return null;                        // unlimited
        return quota / double.Parse(File.ReadAllText(p).Trim());
    }
    return null;
}

Pick one of these as your measured capacity — most server products settle on physical cores for bare-metal/VM and the cgroup quota (rounded up) for containers.

Binding the entitlement into the license

Measuring is only half the job; the measured number has to be checked against something the customer cannot edit. That something is a signed entitlement. The license carries a maxCores value in its payload, signed by your private key, and the client verifies the signature before trusting it (the mechanics are in licensing entitlements and feature flags and the anatomy of a signed key). Then, at startup:

var license = keyright.Validate();              // verifies signature against embedded public key
int licensedCores = license.GetInt("maxCores"); // trusted entitlement from the signed payload
int measured = MeasureLicensingCores();          // one of the numbers above, chosen per policy

if (measured > licensedCores)
{
    // Fail closed — but loudly and actionably.
    throw new LicenseException(
        $"This license permits {licensedCores} cores; this host exposes {measured}. " +
        $"Reduce the CPU allocation or upgrade the license.");
}

Two design notes worth the ink. First, fail closed, but diagnose: an operator who accidentally scheduled your service on a bigger node should read exactly what happened — measured versus licensed — not watch the process die with a generic error. Second, consider a short grace or a one-time warning before enforcement bites, so a transient over-provision during a rollout does not take down production; this mirrors the grace thinking in license server downtime and grace windows. Because the core count is read locally and the entitlement is verified locally, the whole check works offline and on every launch without a phone-home.

Doing this with Keyright

Keyright lets you put a maxCores (or maxLogicalProcessors, or a container-quota) entitlement into the signed license, and its .NET SDK verifies that payload against a public key you embed at build time — so the comparison between licensed and measured cores runs locally, offline, and fail-closed, with the diagnostic message you choose. You decide the counting policy; Keyright gives you the tamper-resistant number to check it against. It is self-hosted, so your signing key and your customers’ license data stay on infrastructure you control.

See the plans, or start from adding licensing to a .NET app and add the core-count entitlement to the model you already have.

Try Nebula.NET

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