Canonical signing of license payloads: why field order and encoding decide whether your signature verifies
A signature is computed over bytes, not over fields — so if your issuing service and your client serialize the license payload even slightly differently, verification fails on a perfectly genuine license. This is how to canonicalize a payload once (stable field order, invariant number and date formatting, UTF-8, base64url) so one signature verifies identically in .NET, Node, Python, and Java.
Signatures are the foundation of offline licensing — a client trusts a license because it can verify, with an embedded public key, that your issuing service signed it and nothing has changed since. But that trust rests on a detail that is easy to get wrong and brutal to debug: a signature is not computed over your fields, it is computed over bytes. The verifier re-derives bytes from the claims it received and asks whether the signature matches those exact bytes. If the issuer’s bytes and the verifier’s bytes differ by a single space, a reordered key, or a differently formatted number, verification fails — on a license that is completely genuine. Get two languages involved, each with its own serializer and its own locale, and “the signature won’t verify” becomes a cross-team mystery.
The cure is canonicalization: defining one deterministic byte representation of the payload that every participant — your .NET issuing service and your .NET, Node, Python, and Java clients — reconstructs identically. This post is about how to do that precisely, and about the specific, silent ways it breaks across languages.
A signature signs bytes, not meaning
Start from what the primitives actually do. ECDSA.SignData(bytes) or RSA.SignData(bytes) take a byte array, hash it, and sign the hash. VerifyData(bytes, signature) hashes the bytes it is given and checks them against the signature. Neither function knows anything about JSON, fields, or intent. Two payloads that a human would call “the same license” are the same only if they serialize to the same bytes.
So the real contract between issuer and client is not “these fields” — it is “these bytes, produced this exact way.” Canonicalization is the written specification of that production. As the anatomy of a signed license key puts it, you verify the signature first and trust the claims second; canonicalization is what makes that verification reproducible in the first place.
The canonical form, field by field
A canonical payload is a function from claims to a single byte string, with every degree of freedom pinned down. Here is a form that travels well across languages:
- Field order is fixed. Either sort keys lexicographically by their UTF-8 bytes, or define an explicit ordered field list in the spec. Both work; the only wrong answer is “whatever order the serializer happened to emit.” Most JSON libraries do not guarantee order, and some reorder on round-trip.
- Numbers use invariant culture, no thousands separators, no leading
+.5, not5.0, not5,00. Integers stay integers. If you must carry decimals, fix the precision and the decimal point (.) in the spec — never let a German locale emit1,50where an English one emits1.50. - Booleans are lowercase
true/false. NotTrue(Python’s defaultstr(bool)), not1/0. - Dates are ISO-8601 in UTC with a trailing
Z, at a fixed precision. Decide once whether you include milliseconds, and always include the offset.2027-01-15T00:00:00Z— never a local time, never an implicit zone. - Strings are UTF-8, and you pick a Unicode normalization form (NFC is the usual choice) so that accented characters compare byte-for-byte across platforms.
- No incidental whitespace between tokens, and no trailing newline.
- The signature is base64url without padding, using the
-/_alphabet, so it is safe to embed in files and URLs and decodes the same everywhere.
The point is not that these particular choices are the only correct ones — it is that some choice must be written down and obeyed by every SDK. The canonical form is a protocol, not an implementation detail.
Building and signing the canonical bytes in C#
Here is an issuing-side example that builds a canonical string with a fixed field list and signs it with ECDSA on the NIST P-256 curve. The claims are written in a deterministic order, numbers and dates are formatted with CultureInfo.InvariantCulture, and the result is encoded as UTF-8 before signing.
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
static string Canonicalize(License lic)
{
// Fixed field order, invariant formatting, '\n' separators. This exact
// string is the contract — every SDK must rebuild it byte-for-byte.
var sb = new StringBuilder();
sb.Append("product=").Append(lic.Product).Append('\n');
sb.Append("licensee=").Append(lic.Licensee).Append('\n');
sb.Append("tier=").Append(lic.Tier).Append('\n');
sb.Append("seats=").Append(lic.Seats.ToString(CultureInfo.InvariantCulture)).Append('\n');
sb.Append("issuedUtc=").Append(lic.IssuedUtc.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture)).Append('\n');
// Features: sorted, comma-joined, so order of the source list never matters.
sb.Append("features=").Append(string.Join(",", lic.Features.OrderBy(f => f, StringComparer.Ordinal)));
return sb.ToString();
}
static string Base64Url(byte[] data) =>
Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_');
// --- issuing side (private key stays here) ---
byte[] canonical = Encoding.UTF8.GetBytes(Canonicalize(license));
byte[] signature = ecdsaPrivate.SignData(canonical, HashAlgorithmName.SHA256);
string sigField = Base64Url(signature);
Two details carry the weight. ToString(CultureInfo.InvariantCulture) is what stops a machine configured for a comma-decimal locale from serializing seats or any number differently from a machine configured for a point-decimal one. And sorting features before joining means the client can send its feature list in any order and still rebuild the identical canonical line. The separators (\n between lines, , between features, = between key and value) are fixed by the spec — the client must use the same ones.
Verifying with the public key only
The client holds only the public key. It parses the received claims, rebuilds the canonical bytes with the same function, decodes the signature from base64url, and verifies. It never re-signs anything, and it trusts no field until the signature checks out.
static byte[] FromBase64Url(string s)
{
s = s.Replace('-', '+').Replace('_', '/');
s = (s.Length % 4) switch { 2 => s + "==", 3 => s + "=", _ => s };
return Convert.FromBase64String(s);
}
// --- client side (only the public key is embedded) ---
byte[] canonical = Encoding.UTF8.GetBytes(Canonicalize(received)); // SAME function
byte[] signature = FromBase64Url(received.Signature);
bool ok = ecdsaPublic.VerifyData(canonical, signature, HashAlgorithmName.SHA256);
if (!ok)
return LicenseInfo.Invalid("signature did not verify"); // fail closed
// only now are product, tier, seats, features trustworthy
Notice the FromBase64Url helper re-adds padding before decoding, because .NET’s Convert.FromBase64String requires it. A Node or Python decoder might accept the unpadded form directly — which is exactly the kind of per-language difference that has to be settled in the spec rather than discovered in production. (For where this verify step sits in a full offline flow — node-locking, revocation, failing closed — see offline license validation explained.)
A concrete cross-language mismatch
Here is the failure in the flesh. Suppose the issuing service signs the canonical string above, and a Node client re-implements Canonicalize. A developer, wanting the UI to show a trial badge, adds one line to the client’s version of the function:
// Node client — WRONG: appends a field the issuer never signed
function canonicalize(lic) {
let s = "";
s += "product=" + lic.product + "\n";
s += "licensee=" + lic.licensee + "\n";
s += "tier=" + lic.tier + "\n";
s += "seats=" + lic.seats + "\n";
s += "issuedUtc=" + lic.issuedUtc + "\n";
s += "features=" + lic.features.slice().sort().join(",");
s += "\nisTrial=" + lic.isTrial; // <-- extra line, not in the signed form
return s;
}
The issuer signed six fields. The client hashes seven. The byte strings differ by the trailing \nisTrial=false, so verify returns false on every license this client sees — genuine ones included. Nothing looks wrong: the claims are all correct, the key is correct, the algorithm matches. The only defect is that one side canonicalized a different byte string.
The fix is not to make the client “lenient.” It is to agree that the canonical form is exactly the six fields the issuer signs, and that isTrial — if it is even part of the license — is one of those signed fields on both sides, formatted identically, in its defined position. A flag that influences behavior but is not in the signed payload is either unsigned data you must not trust, or a field you forgot to canonicalize. There is no third option. The same discipline kills the subtler variants of this bug: a client that joins features with , instead of ,, one that emits the date without the Z, one that lets JSON.stringify reorder keys, one that leaves a trailing newline the issuer trimmed.
The pitfalls, named
Every one of these changes the bytes without changing the apparent meaning, which is why they are so hard to spot:
- Appending unsigned flags. The worked example above. A marker, a toggle, a UI hint — if one side folds it into the canonical string and the other does not, nothing verifies.
- Trailing whitespace and newlines. A serializer that pretty-prints, an editor that adds a final newline, a
WriteLineinstead ofWrite. Trim deliberately and specify the result. - Unicode normalization. A licensee name with an accented character can be stored composed (NFC) on one OS and decomposed (NFD) on another; the two are visually identical and byte-different. Normalize to one form before encoding.
- Number and date locale. Comma vs point decimals, grouped thousands, dates with or without milliseconds or offset. Pin invariant culture and a single date format.
- base64 vs base64url, padded vs unpadded. The
+//vs-/_alphabets and the=padding rule must match on both ends, or the signature bytes decode wrong. - Endianness in binary encodings. If you ever canonicalize to a binary layout instead of text — packing integers or lengths as raw bytes — big-endian on one platform and little-endian on another will silently disagree. Fix the byte order (network/big-endian is the conventional choice) in the spec. Text canonical forms sidestep this entirely, which is one reason to prefer them.
- Serializer key reordering. Many JSON libraries do not preserve insertion order and some reorder on parse/re-emit. Never depend on emitted order; sort or use an explicit field list.
One canonical form, written down
The throughline is simple: the signature is a promise about bytes, so the issuer and every client must manufacture those bytes by the same recipe. Canonicalization is that recipe — fixed field order, invariant numbers, ISO-8601 UTC dates, UTF-8, a chosen Unicode form, base64url for the signature — written into the protocol and implemented identically in .NET, Node, Python, and Java. Keyright’s SDKs share exactly one such canonical definition so that a license signed by the issuing service verifies byte-for-byte in every client language, and the test that matters is not “does it verify in .NET” but “does the same license, parsed and rebuilt in each SDK, produce the identical canonical bytes.” When it does, cross-language verification is boring — which is precisely what you want from cryptography. When it does not, the bug is almost never in the math; it is in the bytes, and the recipe is where you find it.
Try Nebula.NET
Harden your .NET code in minutes — start with the free edition.