Reusable .NET 8 HMAC authentication components for service-to-service APIs.
This project is licensed under the MIT License. See LICENSE.
src/HmacAuth.CoreShared canonicalization, hashing, and signature primitives.src/HmacAuth.AspNetCoreASP.NET Core authentication handler for verifying inbound HMAC requests.src/HmacAuth.HttpClientDelegatingHandlerfor signing outboundHttpClientrequests.samples/HmacAuth.SampleHostRunnable sample API that verifies signed requests.samples/HmacAuth.SampleCallerRunnable sample API that signs outbound requests to the sample host.tests/HmacAuth.TestsEnd-to-end tests covering success, replay rejection, and expired timestamps.
This library is intended for service-to-service authentication:
- API A signs outbound requests.
- API B verifies the signature and authenticates API A as a caller.
Both APIs can reference the same solution, but use different packages:
- API A references
HmacAuth.HttpClientandHmacAuth.Core - API B references
HmacAuth.AspNetCoreandHmacAuth.Core
sequenceDiagram
participant Caller as API A / Caller
participant ClientLib as HmacAuth.HttpClient
participant Host as API B / Host
participant ServerLib as HmacAuth.AspNetCore
participant Store as Credential + Nonce Stores
Caller->>ClientLib: Build outbound request
ClientLib->>ClientLib: Hash body + build canonical request
ClientLib->>ClientLib: Sign with client secret
ClientLib->>Host: Send request with HMAC headers
Host->>ServerLib: Authenticate inbound request
ServerLib->>Store: Resolve client secret and validate nonce
ServerLib->>ServerLib: Recompute body hash + signature
ServerLib-->>Host: Success or Unauthorized
Host-->>Caller: Protected API response
Two runnable sample apps are included:
samples/HmacAuth.SampleHost: secured API listening onhttp://localhost:5081samples/HmacAuth.SampleCaller: caller API listening onhttp://localhost:5082
Run them in separate terminals:
dotnet run --project samples/HmacAuth.SampleHost --launch-profile http
dotnet run --project samples/HmacAuth.SampleCaller --launch-profile httpThen exercise the flow:
curl http://localhost:5081/public/ping
curl http://localhost:5082/call/whoami
curl -X POST http://localhost:5082/call/echo -H "Content-Type: application/json" -d "{\"message\":\"hello\"}"The sample apps share these dev-only credentials through their appsettings.json files:
- client id:
sample-caller - secret:
dev-only-secret
appsettings.json:
{
"HmacAuthentication": {
"AllowedClockSkew": "00:05:00",
"RequireNonceValidation": true
}
}Settings:
AllowedClockSkew: the maximum allowed difference between the request timestamp and the API server clock. Use standardTimeSpanformat such as00:05:00for 5 minutes.RequireNonceValidation: enables replay protection by rejecting reused nonces. Leave this enabled unless you have another replay-prevention mechanism in place.
Program.cs:
using HmacAuth.AspNetCore;
using HmacAuth.Core;
using Microsoft.AspNetCore.Authorization;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddInMemoryHmacCredentialStore(
[new HmacClientCredentials("client-a", "super-secret-key")]);
builder.Services.AddInMemoryHmacNonceStore();
builder.Services.AddAuthentication(HmacAuthenticationDefaults.AuthenticationScheme)
.AddHmac(builder.Configuration.GetSection("HmacAuthentication"));
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/secure", () => "ok")
.RequireAuthorization(new AuthorizeAttribute
{
AuthenticationSchemes = HmacAuthenticationDefaults.AuthenticationScheme,
});
app.Run();What this does:
- resolves the caller secret from
IHmacCredentialStore - rejects reused nonces through
IHmacNonceStore - validates the request timestamp window
- validates the body hash
- authenticates the request with scheme
HMAC
If you prefer code-based registration, AddHmac(options => { ... }) still works.
using HmacAuth.HttpClient;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient("secured-api", client =>
{
client.BaseAddress = new Uri("https://api-b.local/");
})
.AddHmacSigningHandler(options =>
{
options.ClientId = "client-a";
options.Secret = "super-secret-key";
});Call the protected API:
app.MapGet("/call-api-b", async (IHttpClientFactory httpClientFactory) =>
{
var client = httpClientFactory.CreateClient("secured-api");
var response = await client.GetAsync("/secure");
var body = await response.Content.ReadAsStringAsync();
return Results.Text(body, statusCode: (int)response.StatusCode);
});The signing handler adds:
Authorization: HMAC {clientId}:{signature}X-Hmac-TimestampX-Hmac-NonceX-Hmac-Content-SHA256
The in-memory stores are only convenient defaults. For real deployments, replace them with your own implementations:
builder.Services.AddSingleton<IHmacCredentialStore, MyCredentialStore>();
builder.Services.AddSingleton<IHmacNonceStore, MyNonceStore>();Typical production choices:
IHmacCredentialStore: database, configuration-backed client registry, or secret manager lookupIHmacNonceStore: Redis or another shared cache with TTL support
If API B runs on multiple instances, the nonce store should be shared across instances.
The canonical request currently signs:
- client id
- method
- path
- normalized query string
- timestamp
- nonce
- content hash
That means the client and server must agree on:
- request path
- query-string normalization
- UTF-8 body encoding for the content hash
- the shared client secret
- The default replay window is 5 minutes.
- Nonce validation is enabled by default.
- Body hashing requires reading the request body; the ASP.NET Core handler buffers the stream and resets it before your endpoint runs.
GitHub Actions workflows are included for:
- CI on every pushed commit, pull requests, and manual runs
- SAST scanning with GitHub CodeQL on pushes, pull requests, manual runs, and a weekly schedule
- packaging the library projects as NuGet artifacts
- creating a GitHub release and publishing packages to NuGet.org on tags like
v1.0.0 - manual release runs with an explicit version and optional NuGet publish
The release workflow expects a repository secret named NUGET_API_KEY.