Skip to main content

Command Palette

Search for a command to run...

Secure API Design in .NET: HMAC Validation, Anti-Replay, and Timestamp Windows

Updated
6 min read
Secure API Design in .NET: HMAC Validation, Anti-Replay, and Timestamp Windows

Security in API design often begins and ends with OAuth or JWTs. Those models are robust for user-facing systems, but when services communicate directly, such as partner integrations, webhooks, or internal event forwarding, the guarantees change. You’re no longer authenticating who the user is, but whether the request itself is trustworthy.

That’s where HMAC (Hash-Based Message Authentication Code) comes in. It’s a mechanism for proving that the message content hasn’t been tampered with and that it came from a trusted sender who holds a shared secret. But HMAC on its own is not enough. Without additional layers like timestamp validation and anti-replay protection, a valid request could be resent indefinitely by an attacker who intercepted it once.

Below, we’ll build a secure API pattern in .NET 8 that uses HMAC verification, timestamp enforcement, and replay protection, a combination that offers robust security for server to server calls and webhook endpoints.

Understanding HMAC Signatures

An HMAC combines a message and a secret key through a cryptographic hash function (typically SHA-256). The sender computes the hash, includes it in the request (often in a header), and the receiver recomputes it using the same secret. If both hashes match, the message is authentic.

Imagine a simple message:

POST /api/ingest
Body: {"amount": 100}
Secret: my_shared_secret

The signature is computed like so:

HMACSHA256(UTF8("my_shared_secret"), UTF8("POST\n/api/ingest\n{\"amount\":100}"))

The resulting hash is a 256-bit digest unique to that combination of input, secret, and algorithm. Any alteration, even a single byte, changes the result entirely.

Setting up the Middleware

We’ll start by creating a reusable middleware that performs HMAC validation and timestamp checking before any controller logic runs.

HmacValidationMiddleware.cs

using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Caching.Memory;

public class HmacValidationOptions
{
    public string SharedSecret { get; set; } = string.Empty;
    public TimeSpan AllowedClockSkew { get; set; } = TimeSpan.FromMinutes(2);
    public TimeSpan ReplayWindow { get; set; } = TimeSpan.FromMinutes(10);
}

public class HmacValidationMiddleware
{
    private readonly RequestDelegate _next;
    private readonly HmacValidationOptions _options;
    private readonly IMemoryCache _cache;

    public HmacValidationMiddleware(
        RequestDelegate next,
        IOptions<HmacValidationOptions> options,
        IMemoryCache cache)
    {
        _next = next;
        _options = options.Value;
        _cache = cache;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var request = context.Request;
        if (!request.Headers.TryGetValue("X-Signature", out var signatureHeader) ||
            !request.Headers.TryGetValue("X-Timestamp", out var timestampHeader) ||
            !request.Headers.TryGetValue("X-Nonce", out var nonceHeader))
        {
            context.Response.StatusCode = StatusCodes.Status401Unauthorized;
            await context.Response.WriteAsync("Missing security headers.");
            return;
        }

        if (!DateTimeOffset.TryParse(timestampHeader, out var timestamp))
        {
            context.Response.StatusCode = StatusCodes.Status400BadRequest;
            await context.Response.WriteAsync("Invalid timestamp format.");
            return;
        }

        // Check timestamp freshness
        var now = DateTimeOffset.UtcNow;
        if (timestamp < now - _options.AllowedClockSkew || timestamp > now + _options.AllowedClockSkew)
        {
            context.Response.StatusCode = StatusCodes.Status401Unauthorized;
            await context.Response.WriteAsync("Request expired or timestamp invalid.");
            return;
        }

        // Check replay window
        if (_cache.TryGetValue(nonceHeader, out _))
        {
            context.Response.StatusCode = StatusCodes.Status401Unauthorized;
            await context.Response.WriteAsync("Replay detected.");
            return;
        }

        // Cache nonce to prevent reuse
        _cache.Set(nonceHeader.ToString(), true, _options.ReplayWindow);

        // Rewind body for reading
        request.EnableBuffering();
        using var reader = new StreamReader(request.Body, Encoding.UTF8, leaveOpen: true);
        var body = await reader.ReadToEndAsync();
        request.Body.Position = 0;

        var computed = ComputeSignature(request.Method, request.Path, timestampHeader, nonceHeader, body);
        if (!CryptographicOperations.FixedTimeEquals(
                Convert.FromHexString(computed),
                Convert.FromHexString(signatureHeader)))
        {
            context.Response.StatusCode = StatusCodes.Status401Unauthorized;
            await context.Response.WriteAsync("Signature mismatch.");
            return;
        }

        await _next(context);
    }

    private string ComputeSignature(string method, string path, string timestamp, string nonce, string body)
    {
        var payload = $"{method}\n{path}\n{timestamp}\n{nonce}\n{body}";
        var keyBytes = Encoding.UTF8.GetBytes(_options.SharedSecret);
        using var hmac = new HMACSHA256(keyBytes);
        var hashBytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
        return Convert.ToHexString(hashBytes).ToLowerInvariant();
    }
}

Registering the Middleware

Add it to your pipeline in Program.cs:

var builder = WebApplication.CreateBuilder(args);

builder.Services.Configure<HmacValidationOptions>(builder.Configuration.GetSection("Hmac"));
builder.Services.AddMemoryCache();

var app = builder.Build();

app.UseMiddleware<HmacValidationMiddleware>();
app.MapControllers();

app.Run();

Configuration example:

"Hmac": {
  "SharedSecret": "super_secret_shared_key",
  "AllowedClockSkew": "00:02:00",
  "ReplayWindow": "00:10:00"
}

Sending Authenticated Requests

On the client side, we generate a new nonce (unique ID) and timestamp for each request, compute the signature, and send them as headers.

HmacClient.cs

using System.Net.Http;
using System.Security.Cryptography;
using System.Text;

public static class HmacClient
{
    public static async Task<HttpResponseMessage> PostAsync(HttpClient client, string url, string body, string secret)
    {
        var timestamp = DateTimeOffset.UtcNow.ToString("O");
        var nonce = Guid.NewGuid().ToString("N");
        var method = "POST";
        var path = new Uri(url).AbsolutePath;

        var signature = ComputeSignature(secret, method, path, timestamp, nonce, body);

        using var content = new StringContent(body, Encoding.UTF8, "application/json");
        var request = new HttpRequestMessage(HttpMethod.Post, url)
        {
            Content = content
        };

        request.Headers.Add("X-Signature", signature);
        request.Headers.Add("X-Timestamp", timestamp);
        request.Headers.Add("X-Nonce", nonce);

        return await client.SendAsync(request);
    }

    private static string ComputeSignature(string secret, string method, string path, string timestamp, string nonce, string body)
    {
        var payload = $"{method}\n{path}\n{timestamp}\n{nonce}\n{body}";
        var keyBytes = Encoding.UTF8.GetBytes(secret);
        using var hmac = new HMACSHA256(keyBytes);
        var hashBytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
        return Convert.ToHexString(hashBytes).ToLowerInvariant();
    }
}

This guarantees that every request carries a fresh, verifiable cryptographic proof tied to its body content.

Why Fixed Time Comparison Is Good

You might notice the middleware uses CryptographicOperations.FixedTimeEquals. This prevents timing attacks, subtle differences in comparison time that could leak partial information about the correct signature. Even tiny timing discrepancies, measurable over many requests, can reveal a hash bit by bit. Using a fixed time comparison ensures that the check always takes the same amount of time, regardless of how similar or different the inputs are.

Preventing Replays with Nonces and Caching

The X-Nonce header and memory cache combination stop attackers from reusing a previously valid request. Each nonce (unique per request) is stored temporarily. If it appears again within the replay window, the request is rejected immediately. In production, you might use Redis or Azure Cache for Redis instead of an in memory cache to allow distributed verification across multiple instances.

builder.Services.AddStackExchangeRedisCache(o =>
{
    o.Configuration = builder.Configuration.GetConnectionString("Redis");
});

Replace _cache usage with Redis operations to persist replay detection across the cluster.

Enforcing Timestamp Windows

Attackers can sometimes delay or reorder requests. The timestamp ensures each request is only valid for a brief time. The middleware’s check:

if (timestamp < now - AllowedClockSkew || timestamp > now + AllowedClockSkew)

allows a ±2-minute drift between client and server clocks, rejecting requests outside that range. Combined with nonce caching, this closes almost all replay vectors.

Extending for Multi Key Clients

In real systems, different clients (vendors, webhooks, or internal services) often have their own secrets. Extend the middleware to load a secret dynamically based on a X-ClientId header.

if (!context.Request.Headers.TryGetValue("X-ClientId", out var clientId))
{
    context.Response.StatusCode = 400;
    await context.Response.WriteAsync("Missing client ID.");
    return;
}

var secret = _clientSecretStore.GetSecret(clientId);
if (secret is null)
{
    context.Response.StatusCode = 401;
    await context.Response.WriteAsync("Unknown client ID.");
    return;
}

This allows you to rotate or revoke credentials per client, without redeploying.

Combining with HTTPS and Additional Layers

HMAC does not replace HTTPS, it complements it. HTTPS encrypts the transport channel, while HMAC validates message integrity and authenticity. Always enforce TLS 1.2+ for such APIs.

Further enhancements might include:

  • Payload compression detection to ensure clients don’t compress then hash different representations.

  • Canonicalisation to normalise whitespace or header order before hashing.

  • Versioned signature formats (e.g. v1: prefix) to allow future cryptographic changes without breaking existing clients.


HMAC validation, timestamp enforcement, and replay protection form a triad that hardens server to server APIs against tampering, forgery, and replay attacks. In this post, we’ve built a complete example in .NET 8 using middleware, fixed time comparison, nonce caching, and timestamp checks, all lightweight, dependency free techniques. In distributed systems, cryptographic verification is all about confidence. The ability to prove a request’s integrity is as fundamental as authenticating who sent it. Whether you’re securing webhook receivers, internal APIs, or financial integrations, adopting this pattern ensures every request can be trusted, every time.