If you're looking for a lightweight, secure way to handle service-to-service communication, HMAC-based authentication is a great option.
I've been playing around with it, and I’m excited to walk you through a practical example (proof of concept) you can build yourself.
By the end, you'll have a console app simulating two services talking securely—complete with signatures, replay protection, and some handy tweaks.
Let's go there!
What’s HMAC Authentication?
HMAC (Hash-based Message Authentication Code) uses a shared secret key and a hash function (like SHA-256) to sign messages. It's quick, stateless, and guarantees authenticity and integrity.
Imagine Service A
saying, “Here's my message, signed with our secret.” Service B
checks it and replies, “Cool, it’s really you!”
Companies like Stripe use HMAC for webhook security, and AWS leans on it for API request signing.
It's a real-world winner.
Why Use It (and When Not To)?
HMAC is perfect for internal service comms (like microservices in a trusted network) where you want speed without the complexity of TLS or JWTs.
It's ideal for low-latency setups. But it’s not foolproof: a leaked key spells trouble, and it doesn’t encrypt data (use HTTPS for that).
For public APIs or intricate auth needs, OAuth or mutual TLS might suit better.
More on that later.
Let’s Build It: Step-by-Step
Fire up your IDE and create a .NET Core console app.
Let's simulate a sender and receiver.
Here’s the plan:
- Sender Signs a Request
- Receiver Verifies It
- Add Tweaks for Real-World Use
Step 1: Setting Up the Sender
The sender crafts a message, signs it, and sends it over HTTP.
Create a Sender
directory in your project and add a class SenderService
.
Here is the code:
using System.Security.Cryptography;
using System.Text;
public class SenderService
{
private const string SecretKey = "XRpjBq&G&68KWd#9TCxGmxzJbn7vNdKHPJV4&&R"; // <== IMPORTANT: in real=life, this must be stored in appsettings.json, KeyVault, or similar
private const string ApiKey = "4f7a49a7-6ce9-4a2f-82f0-a14e3954617e"; // <== IMPORTANT: same as above
private const string ReceiverUrl = "http://localhost:5000/api/verify";
public static async Task SendRequestAsync()
{
using var client = new HttpClient();
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
const string body = "{\"message\":\"Hello, Receiver!\"}";
var nonce = Guid.NewGuid().ToString("N");
var signature = ComputeSignature(body, timestamp, nonce);
var request = new HttpRequestMessage(HttpMethod.Post, ReceiverUrl)
{
Content = new StringContent(body, Encoding.UTF8, "application/json")
};
request.Headers.Add("X-API-Key", ApiKey);
request.Headers.Add("X-Timestamp", timestamp);
request.Headers.Add("X-Nonce", nonce);
request.Headers.Add("X-HMAC-Signature", signature);
request.Headers.Add("X-Key-Version", "1"); // For future key rotation
// The request is sent here, you might want to debug the middleware in the receiver area
var response = await client.SendAsync(request);
Console.WriteLine($"Sender got: {response.StatusCode} - {await response.Content.ReadAsStringAsync()}");
}
private static string ComputeSignature(string data, string timestamp, string nonce)
{
var message = $"{timestamp}:{nonce}:{data}";
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(SecretKey));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(message));
return Convert.ToBase64String(hash);
}
}
We're signing a JSON payload with a timestamp and nonce (a unique ID) to block replays.
Headers carry the details, and ComputeSignature
ties it all together.
Step 2: Building the Receiver
Now, let’s create a simple API to verify the request with a simple /api/verify
endpoint.
Add Microsoft.AspNetCore.App
to your project.
Create a directory Receiver
and inside, the class ReceiverService
. Add the code (HmacAuthMiddleware
will be added after this):
using Microsoft.Extensions.Hosting;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
namespace hmac_based_authentication.Receiver;
public class ReceiverService
{
public static Task Main() =>
Host.CreateDefaultBuilder()
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.Configure(app =>
{
app.UseRouting();
app.UseMiddleware<HmacAuthMiddleware>();
app.UseEndpoints(endpoints =>
{
endpoints.MapPost("/api/verify", async context =>
{
await context.Response.WriteAsync("Request verified!");
});
});
}).UseUrls("http://localhost:5000");
}).Build().RunAsync();
}
Now, let's create the middleware that will check every incoming request.
Add a HmacAuthMiddleware
class with this code:
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Http;
public class HmacAuthMiddleware
{
private const string SecretKey = "XRpjBq&G&68KWd#9TCxGmxzJbn7vNdKHPJV4&&R"; // <== IMPORTANT: in real life, this MUST be stored in appsettings.json, KeyVault, or similar
private const long TimeTolerance = 300; // 5 minutes
private readonly RequestDelegate _next;
public HmacAuthMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
if (!context.Request.Headers.TryGetValue("X-API-Key", out var apiKey) ||
!context.Request.Headers.TryGetValue("X-Timestamp", out var timestamp) ||
!context.Request.Headers.TryGetValue("X-Nonce", out var nonce) ||
!context.Request.Headers.TryGetValue("X-HMAC-Signature", out var signature))
{
await WriteUnauthorized(context, "Missing headers");
return;
}
if (!long.TryParse(timestamp, out long requestTime) ||
Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - requestTime) > TimeTolerance)
{
await WriteUnauthorized(context, "Invalid timestamp");
return;
}
// Enable buffering for body rewind
context.Request.EnableBuffering();
var body = await new StreamReader(context.Request.Body, Encoding.UTF8).ReadToEndAsync();
context.Request.Body.Position = 0; // Reset for downstream
var computedSignature = ComputeSignature(body, timestamp!, nonce!);
if (computedSignature != signature)
{
await WriteUnauthorized(context, "Invalid signature");
return;
}
await _next(context);
}
private static string ComputeSignature(string data, string timestamp, string nonce)
{
var message = $"{timestamp}:{nonce}:{data}";
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(SecretKey));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(message));
return Convert.ToBase64String(hash);
}
private static async Task WriteUnauthorized(HttpContext context, string message)
{
context.Response.StatusCode = 401;
await context.Response.WriteAsync(message);
}
}
Pro-tip: Why the EnableBuffering()?
In .NET, the request body stream is forward-only by default.
If you read it once (like for HMAC verification), it’s gone for downstream code, like our endpoint logic.
Buffering loads it into memory so you can rewind it with Body.Position = 0
.
It's crucial for middleware setups where multiple components need the body.
Step 3: Tie It Together
In Program.cs
, launch both services:
using hmac_based_authentication.Receiver;
using hmac_based_authentication.Sender;
// Let's start the service that will receive the request first
var receiverService = ReceiverService.Main();
// Wait for the receiver to start
await Task.Delay(1000);
// Now, let's send a request!
await SenderService.SendRequestAsync();
await receiverService;
Run it, and watch the sender ping the receiver, which verifies and responds 💥
You can play with it and it would makes sense to add some breakpoints to debug and understand the flow.
Real-World Examples
- Stripe: Their webhooks use HMAC-SHA256 signatures with timestamps, much like ours, to ensure payload authenticity.
- AWS: Signature Version 4 leverages HMAC for API requests, combining keys and data for secure calls.
Tweaks for Robustness
- Nonce Storage: I used a nonce for replay protection. In production, store these in a memory store (like Redis) and check them to prevent reuse within the 5-minute tolerance window. It’s a best practice for tighter security. Timestamps alone aren't eough if someone spams old requests.
- Buffering: Covered above. It keeps the body readable throughout the pipeline.
- Key Versioning: The
X-Key-Version header
(set to “1” here) isn't used yet, but it's a placeholder for key rotation. Imagine supporting multiple active keys (e.g., v1 and v2) during a transition. The receiver could fetch the right key from a store based on this header, making updates seamless without downtime.
When to Use It
HMAC is your go-to for internal services needing fast, stateless auth. Like an order service pinging inventory.
Avoid it for public-facing APIs (use OAuth or JWT instead) or when encryption is a must (stick with TLS).
Wrapping Up
There you have it: a working HMAC setup you can tweak to your heart's content.
You can check the code in this repo.
Download the code or fork it and play with it.
Happy coding! ⚡