Production-Safe HMAC Authentication and Verification in C#
Learn how to authenticate message integrity with HMAC-SHA-256 in C#, manage server-side keys, avoid canonicalization errors, rotate keys, and verify tags without timing-sensitive comparisons.
Learning outcome
You will be able to explain what HMAC guarantees, distinguish it from hashing, encryption, and digital signatures, and implement HMAC-SHA-256 verification with server-side key handling and timing-safe comparison. You will also recognize replay, rotation, and canonicalization concerns that HMAC alone does not solve.
Intuition
A plain hash detects accidental changes only when the trusted hash is protected separately. An attacker who can replace both a message and its ordinary hash can simply calculate a new hash.
HMAC combines a cryptographic hash with a secret key. A party without that key cannot feasibly produce a valid authentication tag for a modified message. It provides integrity and symmetric authentication, but it does not hide the message.
Encryption provides confidentiality and may provide integrity when an authenticated-encryption mode is used. A digital signature uses an asymmetric private/public key pair, supports public verification, and can distinguish the signer from verifiers. HMAC is symmetric: every verifier holding the key can also generate tags, so it does not provide non-repudiation.
Deep dive
HMAC-SHA-256 accepts an arbitrary byte sequence and a secret key, producing a 32-byte tag. Authentication applies to bytes, not abstract objects. Both sides must therefore agree on the exact UTF-8 encoding, field order, separators, number formatting, casing, Unicode normalization, and treatment of whitespace.
For structured data, define one canonical representation or authenticate the original request bytes before parsing. Avoid ambiguous concatenation such as adjacent variable-length fields; use explicit framing, length prefixes, or an unambiguous standardized serialization. Include security-relevant metadata such as a timestamp, nonce, HTTP method, resource identifier, and content type when the protocol requires them.
Generate production keys with a cryptographically secure random source and store them in a server-side secret manager, key vault, or similarly protected configuration channel. Do not embed production keys in source, distribute them to browsers or other untrusted clients, or write them to logs. The trusted sender and verifier both need the shared key; either can create a valid tag. Use separate keys for separate protocols or purposes to limit cross-protocol misuse.
For rotation, attach a non-secret key identifier to each authenticated envelope. Sign new messages with the current key and temporarily retain narrowly scoped previous keys for verification. Bound the overlap, monitor old-key use, and retire compromised keys promptly. Select a key by identifier rather than trying an unbounded key collection.
HMAC does not prevent replay. Calling this example's Verify twice with the same key, message, and tag succeeds twice. An authenticated timestamp with an enforced age limit only narrows the replay window; it does not reject a duplicate inside that window. If an operation must be accepted once, authenticate a nonce or message identifier and atomically record its first accepted use in the correct sender and protocol scope. Verify the tag before reserving that identifier, and retain the record for the full period in which the message could still be accepted.
Failure modes
- Using an unkeyed SHA-256 digest as though it authenticated an attacker-controlled message.
- Comparing tags with ordinary equality, an early-exit loop, or encoded strings rather than a timing-safe byte comparison.
- Computing the tag over parsed data while the sender authenticates original bytes.
- Authenticating a body but omitting routing, identity, amount, timestamp, or other security-relevant context.
- Reusing one HMAC key across unrelated protocols or tenants without a deliberate key hierarchy.
- Logging keys, authorization headers, or diagnostic objects that contain secret material.
- Rotating immediately without a controlled verification overlap, or accepting old keys forever.
- Treating a valid tag as proof of freshness, confidentiality, or a particular public signer.
Before fixed-time comparison, a production protocol should decode the supplied tag strictly and require the expected 32-byte HMAC-SHA-256 length. Rejecting a malformed public length is acceptable; compare valid equal-length candidate tags with FixedTimeEquals.
Executable HMAC primitive example
The executable example uses a deterministic fixture key so its output is stable. That key is intentionally not a production secret. A deployed service should obtain key bytes from a server-side secret provider, associate them with a key identifier, avoid converting them to loggable strings, and clear mutable buffers when practical.
The program authenticates one exact UTF-8 message and then performs three checks: the original message succeeds, a changed message fails, and the original message with a different key fails. It never prints either key or the authentication tag.
Expected output on .NET 10:
Valid message: True Changed message: False Wrong key: False
The message is exactly 37 UTF-8 bytes, with no byte-order mark or trailing newline. Adding a newline, changing field order, or reformatting the amount produces a different authenticated input. The example accepts tag bytes directly; it does not implement a wire-format decoder, freshness checks, replay storage, or a complete HTTP authentication protocol.
Code walkthrough
HMACSHA256.HashData computes the tag over the UTF-8 bytes. Verification independently recomputes the candidate tag and passes the two byte sequences to CryptographicOperations.FixedTimeEquals; ordinary sequence equality is not used. Temporary computed tags and fixture key arrays are cleared after use.
The changed-message test demonstrates integrity, while the wrong-key test demonstrates symmetric key possession. Neither test demonstrates replay prevention or confidentiality. In a real protocol, verification should occur before acting on the authenticated message, and failure responses should avoid revealing unnecessary diagnostic distinctions.
Interview drill
- Why can an attacker replace both a message and its plain SHA-256 hash, but not normally produce a valid HMAC without the key?
- Why does HMAC provide no confidentiality or non-repudiation?
- Why is
FixedTimeEqualspreferable to ordinary equality for authentication tags? - Which fields would you authenticate for an HMAC-authenticated webhook request, and how would you prevent replay?
- How would a key identifier and bounded overlap support rotation without testing every historical key?
Exercise: Extend the protocol design on paper to authenticate a timestamp, nonce, method, path, and body. Specify an unambiguous byte framing, an allowed clock skew, nonce retention, exact tag encoding, and rotation behavior. Do not merely concatenate variable-length strings.
Answer cues
- Anyone can recompute the plain digest after editing this example's amount. Producing the corresponding HMAC also requires the shared key.
- The amount remains visible in the message. Because the verifier has the same key, a valid tag cannot distinguish which key holder generated it.
- Compare decoded tag bytes with the cryptographic comparison API already used below. An ordinary string or byte loop can reveal how far a candidate matched. This does not make the whole request-processing pipeline constant-time.
- Bind the operation's relevant context as well as its body. In the exercise, timestamp validation limits age; a shared atomic nonce record rejects a second accepted use.
- Resolve a known key identifier to the permitted key and sender scope. During a planned overlap, accept only the explicitly allowed versions; a compromised version may need immediate retirement.
Worked paper design
For a synthetic orders webhook, frame each field as a four-byte unsigned big-endian byte length followed by that many bytes. Fix the ordered fields as protocol version, key identifier, sender, purpose, timestamp, nonce, method, path plus query, content type, and body. Enforce a maximum envelope size and bounded field lengths before allocating or parsing those fields. Use UTF-8 for the text fields and the original bytes for the body; specify timestamp units and decimal formatting. Do not silently normalize or reserialize received fields. Define how a proxy's path rewriting is handled before deploying the protocol.
Example policy: permit at most 300 seconds of message age and 30 seconds of future skew. After tag and freshness validation, atomically claim (sender, protocol, nonce) in a shared replay store before the business action. Retain the claim past the latest acceptance time, including the documented clock tolerance. Two concurrent copies must not both claim it. A replay-store outage must not silently permit duplicates; define a rejection or retry policy. Claiming a nonce does not make the business action exactly-once: a crash after the claim could prevent a legitimate retry from completing. Define durable acceptance, work status, and idempotent recovery separately so a failed action is not silently lost. This is a protocol-design exercise, not a claim that the small console program implements these controls.
Revision checklist
- Use HMAC, not a plain hash, for symmetric message authentication.
- Authenticate a precisely specified byte representation and all relevant context.
- Keep strong random keys server-side; never log, expose, or commit them.
- Separate keys by purpose and identify versions with non-secret key IDs.
- Decode tags strictly, enforce the algorithm's tag length, and compare with
FixedTimeEquals. - Bound message age with authenticated freshness data; reject duplicate identifiers atomically when one-time acceptance is required.
- Rotate with a short monitored overlap and an explicit retirement plan.
- Use authenticated encryption when confidentiality is also required; use signatures when public verification is required.
Executable code examples
Authenticate and verify a UTF-8 message with HMAC-SHA-256
Program.cs
using System;
using System.Security.Cryptography;
using System.Text;
internal static class Program
{
private static void Main()
{
// Deterministic test fixtures only. Load production keys from a
// server-side secret provider, and never log them.
byte[] key = Convert.FromHexString(
"000102030405060708090A0B0C0D0E0F" +
"101112131415161718191A1B1C1D1E1F");
byte[] wrongKey = Convert.FromHexString(
"202122232425262728292A2B2C2D2E2F" +
"303132333435363738393A3B3C3D3E3F");
const string message = "orderId=123&amount=49.95¤cy=USD";
try
{
byte[] tag = HMACSHA256.HashData(
key,
Encoding.UTF8.GetBytes(message));
try
{
Console.WriteLine($"Valid message: {Verify(key, message, tag)}");
Console.WriteLine($"Changed message: {Verify(key, message + "&rush=true", tag)}");
Console.WriteLine($"Wrong key: {Verify(wrongKey, message, tag)}");
}
finally
{
CryptographicOperations.ZeroMemory(tag);
}
}
finally
{
CryptographicOperations.ZeroMemory(key);
CryptographicOperations.ZeroMemory(wrongKey);
}
}
private static bool Verify(
ReadOnlySpan<byte> key,
string message,
ReadOnlySpan<byte> expectedTag)
{
byte[] actualTag = HMACSHA256.HashData(
key,
Encoding.UTF8.GetBytes(message));
try
{
return CryptographicOperations.FixedTimeEquals(
actualTag,
expectedTag);
}
finally
{
CryptographicOperations.ZeroMemory(actualTag);
}
}
}