A license check is the single event that decides whether your software is allowed to run tonight, and it is almost always logged in one of two wrong ways. Either it is logged as nothing — the verification happens, succeeds or fails silently, and the first anyone hears of a lapsed key is a user complaint — or it is logged as a disaster: log.LogInformation("license: {@License}", info), which serialises the whole object and writes the signed key, the raw signature and the licensee's email into a file that will be shipped to an aggregator and pasted into a support ticket by lunchtime.
This post builds the version in between: one structured, queryable event per verification, emitted so that an operator can read it, alert on it, and correlate it — and so that the key itself never leaves the process. It is about logging a Keyright verification the way you would log an auth event, because that is what it is.
The thing you are verifying
Keyright's Validate() returns a LicenseInfo with a Status, a Licensee, an expiry and the derived DaysRemaining/IsExpiringSoon that the renewal and health-check work already lean on:
LicenseInfo info = client.Validate(licenseKey);
// info.Status -> LicenseStatus (Valid, Expired, Revoked, SignatureInvalid, ...)
// info.Licensee -> "Acme GmbH"
// info.ExpiryUtc -> DateTime?
// info.DaysRemaining -> int
// info.IsExpiringSoon-> bool
The licenseKey in that call is the thing that must never reach the log. Everything else on info is fair to record; the key and the signature are not. So the first design decision is to compute a fingerprint of the key once, and pass that to the logger — never the key.
using System.Security.Cryptography;
using System.Text;
static string Fingerprint(string licenseKey)
{
// A stable, non-reversible handle: first 8 hex of SHA-256 over the key.
// Enough to say "same key as the one in the ticket?" — not enough to be the key.
Span<byte> hash = stackalloc byte[32];
SHA256.HashData(Encoding.UTF8.GetBytes(licenseKey), hash);
return Convert.ToHexString(hash)[..8].ToLowerInvariant(); // e.g. "9f3a1c20"
}
If your payload carries a key-id claim, prefer that — it is already a non-secret handle. The fingerprint is the fallback for keys that do not.
One event, declared once
The redaction rule is only as good as the number of places it has to hold. Spread the logging across a dozen log.LogInformation("...") call sites and the one that passes licenseKey instead of fingerprint is the leak. So declare the event once, as a source-generated LoggerMessage, whose signature makes the raw key unpassable:
using Microsoft.Extensions.Logging;
public static partial class LicenseLog
{
// Note the parameters: fingerprint, status, licensee, days — never the key.
[LoggerMessage(
EventId = 4000, Level = LogLevel.Information,
Message = "License verified: {Status} for {Licensee} (key {Fingerprint}), {DaysRemaining} day(s) remaining")]
public static partial void Verified(
this ILogger logger,
string status, string licensee, string fingerprint, int daysRemaining);
[LoggerMessage(
EventId = 4001, Level = LogLevel.Warning,
Message = "License expiring: {Status} for {Licensee} (key {Fingerprint}) expires in {DaysRemaining} day(s)")]
public static partial void Expiring(
this ILogger logger,
string status, string licensee, string fingerprint, int daysRemaining);
[LoggerMessage(
EventId = 4002, Level = LogLevel.Error,
Message = "License invalid: {Status} for {Licensee} (key {Fingerprint})")]
public static partial void Invalid(
this ILogger logger,
string status, string licensee, string fingerprint);
}
The source generator turns each of these into a zero-allocation method that checks the level, writes the named fields and formats the message only if a sink wants it. There is no licenseKey parameter on any of them, so a caller cannot log the key through this type even by accident — the compiler stops them. That is the whole reason to prefer the generator over a free-form template: the rule is now a method signature, not a code-review convention.
Mapping status to level
The dispatch from LicenseStatus to one of those three events is the translation table, and it lines up with how alerting already works — Information is quiet, Warning is noticed, Error is counted and paged:
public static void LogLicense(this ILogger logger, LicenseInfo info, string fingerprint)
{
string status = info.Status.ToString();
string licensee = info.Licensee ?? "(none)";
switch (info.Status)
{
case LicenseStatus.Valid when info.IsExpiringSoon:
logger.Expiring(status, licensee, fingerprint, info.DaysRemaining);
break;
case LicenseStatus.Valid:
logger.Verified(status, licensee, fingerprint, info.DaysRemaining);
break;
default: // Expired, Revoked, SignatureInvalid, MachineMismatch, ClockTampered, NoLicense
logger.Invalid(status, licensee, fingerprint);
break;
}
}
A JSON console sink then renders the healthy case like this — note that the key is nowhere in it, only its fingerprint:
{
"Timestamp": "2026-10-16T09:00:01.42Z",
"LogLevel": "Information",
"EventId": 4000,
"Message": "License verified: Valid for Acme GmbH (key 9f3a1c20), 48 day(s) remaining",
"State": { "Status": "Valid", "Licensee": "Acme GmbH", "Fingerprint": "9f3a1c20", "DaysRemaining": 48 }
}
Because Status, Fingerprint and DaysRemaining are named structured fields and not just text baked into the message, a log backend can graph "distinct fingerprints seen this week" or alert on "EventId 4002 count > 0" without parsing the message string. That is the dividend of the structured form: the event is queryable, not just readable.
A scope so the rest of the logs correlate
The verification is one line, but the work that follows it — a background lease renewal, an entitlement check, a feature gate — produces its own logs, and an incident reviewer wants to tie them to the license that was in force. Open a logging scope keyed by the fingerprint around that work, and every line inside it carries the field automatically:
using (logger.BeginScope(new Dictionary<string, object> { ["LicenseKey"] = fingerprint }))
{
logger.LogLicense(info, fingerprint);
// ... anything logged in here also carries LicenseKey=9f3a1c20 ...
}
Now a SignatureInvalid at 03:00 and a failed entitlement check at 03:01 share a LicenseKey field, and the reviewer can pull the whole story with one filter. The scope carries the fingerprint, never the key — the same rule, applied to the correlation handle.
Prove the key never escapes
The one assertion worth writing is the negative one: that the raw key does not appear in anything the logger emits. Microsoft.Extensions.Diagnostics.Testing ships a FakeLogger that captures records in memory, so the test is direct:
[Fact]
public void Verification_never_logs_the_raw_key()
{
var fake = new FakeLogger<LicenseService>();
const string key = "KR1-REALKEY-abcdef ..."; // the actual signed key
var info = new LicenseInfo { Status = LicenseStatus.Valid, Licensee = "Acme", DaysRemaining = 48 };
fake.LogLicense(info, Fingerprint(key));
foreach (FakeLogRecord rec in fake.Collector.GetSnapshot())
{
Assert.DoesNotContain(key, rec.Message);
foreach (var kv in rec.StructuredState ?? [])
Assert.DoesNotContain(key, kv.Value ?? ""); // check the structured fields too
}
}
Assert on both the rendered message and the structured state, because a leak can hide in either — a stray {Key} in a template shows up in the message, a BeginScope that captured the key shows up in the state. This test is the thing that keeps the rule true as the code grows: the day someone adds a fourth event that passes the key, it goes red.
The takeaway
A license check deserves to be logged like the auth event it is: one structured record, not silence and not a data dump. Declare the event once as a source-generated LoggerMessage whose parameters are a fingerprint and the non-secret fields, so the raw key is unpassable by construction. Map LicenseStatus onto levels that your existing alerting already understands — Information for valid, Warning for expiring, Error for invalid — and give each a stable EventId so a dashboard counts failures without string-matching. Emit it on verification and on transitions, not on every request. Wrap the work in a scope keyed by the fingerprint so the rest of the logs correlate. Then write the one test that proves the key never reaches the sink, and let it guard the rule forever. The result is a log an operator can read, a backend can query, and a support ticket can safely carry — because the one thing that must not travel never made it past the first line.
Top comments (0)