ACYTEC.Security.Hmac.Client 1.0.2

dotnet add package ACYTEC.Security.Hmac.Client --version 1.0.2
                    
NuGet\Install-Package ACYTEC.Security.Hmac.Client -Version 1.0.2
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="ACYTEC.Security.Hmac.Client" Version="1.0.2" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="ACYTEC.Security.Hmac.Client" Version="1.0.2" />
                    
Directory.Packages.props
<PackageReference Include="ACYTEC.Security.Hmac.Client" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add ACYTEC.Security.Hmac.Client --version 1.0.2
                    
#r "nuget: ACYTEC.Security.Hmac.Client, 1.0.2"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package ACYTEC.Security.Hmac.Client@1.0.2
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=ACYTEC.Security.Hmac.Client&version=1.0.2
                    
Install as a Cake Addin
#tool nuget:?package=ACYTEC.Security.Hmac.Client&version=1.0.2
                    
Install as a Cake Tool

ACYTEC.Security.Hmac.Client

The calling-side counterpart to ACYTEC.Security.Hmac.AspNetCore. Register it once and get a ready to use HttpClient that transparently signs the token request, caches and refreshes the bearer token, and encrypts and decrypts AES-GCM payloads, matching the host package's protocol exactly. Works the same way in ASP.NET Core MVC apps, Web APIs, and Azure Functions backends — this package has no ASP.NET Core hosting dependency.

If you're new to this codebase: the host package (ACYTEC.Security.Hmac.AspNetCore, in the sibling ACYTECEndpointSecurity repository) protects an API with HMAC-signed token issuance, short lived JWTs, origin validation, and AES-GCM payload encryption. This package is what a calling app installs to talk to that API without hand-rolling any of that. The wire protocol both sides implement is documented in PROTOCOL.md in the host repository, including a worked test vector.

Install

dotnet add package ACYTEC.Security.Hmac.Client

How it works

  1. AddACYTECHmacClient registers two HttpClients: an internal one used only to call the host's token endpoint, and a named one (ACYTECHmacClientOptions.HttpClientName) that your code uses for everything else.
  2. The first time you send a request through the named client, ACYTECHmacDelegatingHandler asks ITokenProvider for an access token. CachingTokenProvider doesn't have one yet, so it signs a request to the host's token endpoint (HmacRequestSigner), decrypts the response envelope, and caches the token in memory.
  3. The handler attaches that token as Authorization: Bearer ... and the configured X-App-Origin header, encrypts your request body if it has one, sends the request, and decrypts the response body if the call succeeded.
  4. Subsequent calls reuse the cached token until it's within TokenRefreshBuffer of expiry, at which point the next call transparently fetches a new one.

None of this is visible to your code. You inject IHttpClientFactory, create the named client, and call it like any other HttpClient.

Quick start

// Program.cs — ASP.NET Core Web API / MVC, Azure Functions isolated worker,
// or any generic host (Host.CreateApplicationBuilder)
using ACYTEC.Security.Hmac.Client.Extensions;

builder.Services.AddACYTECHmacClient(builder.Configuration);
// Anywhere you'd normally inject IHttpClientFactory
using ACYTEC.Security.Hmac.Client.Options;

public sealed class UserSearchService(IHttpClientFactory httpClientFactory)
{
    public async Task<string> SearchAsync(string query, CancellationToken ct)
    {
        var client = httpClientFactory.CreateClient(ACYTECHmacClientOptions.HttpClientName);

        var response = await client.GetAsync($"/users/search?query={Uri.EscapeDataString(query)}", ct);
        response.EnsureSuccessStatusCode();

        return await response.Content.ReadAsStringAsync(ct);
    }
}

No manual HMAC signing, no manual token handling, no manual encryption.

Call AddACYTECHmacClient once at startup. Calling it twice registers the bearer token and encryption handling twice on the same named client.

Usage examples

GET with a typed response

System.Net.Http.Json works normally — the handler decrypts the response body before your code ever sees it, so ReadFromJsonAsync deserializes the plain JSON underneath the envelope, not the envelope itself.

using System.Net.Http.Json;
using ACYTEC.Security.Hmac.Client.Options;

public sealed record UserSearchResult(string Query, string[] Results);

public sealed class UserSearchClient(IHttpClientFactory httpClientFactory)
{
    public async Task<UserSearchResult?> SearchAsync(string query, CancellationToken ct)
    {
        var client = httpClientFactory.CreateClient(ACYTECHmacClientOptions.HttpClientName);

        return await client.GetFromJsonAsync<UserSearchResult>(
            $"/users/search?query={Uri.EscapeDataString(query)}", ct);
    }
}

POST with a JSON body

Set request.Content however you normally would; the handler reads it, replaces it with an encrypted envelope, and sends that instead. Your code never constructs the envelope itself.

using System.Net.Http.Json;
using ACYTEC.Security.Hmac.Client.Options;

public sealed record CreateOrderRequest(string Sku, int Quantity);
public sealed record CreateOrderResponse(Guid OrderId);

public sealed class OrdersClient(IHttpClientFactory httpClientFactory)
{
    public async Task<CreateOrderResponse?> CreateAsync(CreateOrderRequest order, CancellationToken ct)
    {
        var client = httpClientFactory.CreateClient(ACYTECHmacClientOptions.HttpClientName);

        var response = await client.PostAsJsonAsync("/orders", order, ct);
        response.EnsureSuccessStatusCode();

        return await response.Content.ReadFromJsonAsync<CreateOrderResponse>(cancellationToken: ct);
    }
}

Handling errors

Error responses, 401 from a rejected token request, 400 from a decryption failure on the host, 404, 5xx, and so on, are plain application/problem+json and pass through the handler untouched, they are never treated as encrypted envelopes. Use the response status code and body exactly as you would with any other HttpClient call:

var response = await client.GetAsync("/users/search?query=lovelace", ct);

if (!response.IsSuccessStatusCode)
{
    var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>(cancellationToken: ct);
    logger.LogWarning("Host rejected the call: {Status} {Title}", response.StatusCode, problem?.Title);
    return null;
}

Azure Functions (isolated worker)

Registration is identical to any other generic host, since this package has no ASP.NET Core dependency:

// Program.cs
using ACYTEC.Security.Hmac.Client.Extensions;

var builder = FunctionsApplication.CreateBuilder(args);
builder.ConfigureFunctionsWebApplication();

builder.Services.AddACYTECHmacClient(builder.Configuration);

builder.Build().Run();
public sealed class SearchUsersFunction(IHttpClientFactory httpClientFactory)
{
    [Function("SearchUsers")]
    public async Task<HttpResponseData> Run(
        [HttpTrigger(AuthorizationLevel.Function, "get")] HttpRequestData req)
    {
        var client = httpClientFactory.CreateClient(ACYTECHmacClientOptions.HttpClientName);
        var response = await client.GetAsync("/users/search?query=lovelace");

        var result = req.CreateResponse(response.StatusCode);
        await result.WriteStringAsync(await response.Content.ReadAsStringAsync());
        return result;
    }
}

Console app / worker service

using ACYTEC.Security.Hmac.Client.Extensions;
using ACYTEC.Security.Hmac.Client.Options;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddACYTECHmacClient(builder.Configuration);

using var host = builder.Build();

var httpClientFactory = host.Services.GetRequiredService<IHttpClientFactory>();
var client = httpClientFactory.CreateClient(ACYTECHmacClientOptions.HttpClientName);

var response = await client.GetAsync("/users/search?query=lovelace");
Console.WriteLine(await response.Content.ReadAsStringAsync());

See samples/SampleClientApp in this repository for a runnable version of this, wired up against samples/SampleHostApi in the host repository.

What's on the wire

If you point a proxy or Wireshark at traffic from this client, here's what you'll actually see. The point of the package is that none of this is readable without the client's Secret.

Token request (POST /auth/token) carries no body, just the signed headers:

POST /auth/token HTTP/1.1
Host: userapi.acytec.lk
X-Client-Id: evcrm
X-Timestamp: 1756195200
X-Nonce: 9f1c2b7a4e1d4c3e8a2f6b0d5c7e1a3b
X-Signature: kR3xVv9m1sQpZ2f8Lw6Yt4Nc0Xj7Bh2Ea5Dg1Fi3Ck=
Content-Length: 0

Token response, and every other 2xx response and non-empty request body, is an EncryptedEnvelope, never the plain JSON you sent or expect back:

{
  "payload": "vN2vQmYVX3ke9dW/3s0PGqjXtq7DrIw2PZ8Q1QpTwjRfvHZTthCUOxvUuY7wynHwvpF3jP6Xnkq/6h4=",
  "nonce": "u7CqzT2n8YB4p9kL",
  "tag": "R7dJcXwYVpNQGkq8h4v9zg=="
}

payload is the AES-256-GCM ciphertext, nonce is the 12 byte value used for that encryption, tag is the 16 byte authentication tag, all base64. Decrypted, the token response looks like {"accessToken":"eyJhbGciOi...","expiresAt":"2026-08-26T13:15:00Z"}, but an interceptor never gets that far without the shared secret.

A normal call, e.g. POST /orders from the example above, looks the same on the wire:

POST /orders HTTP/1.1
Host: userapi.acytec.lk
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
X-App-Origin: https://evcrm.acytec.lk
Content-Type: application/json
Content-Length: 132

{"payload":"h3Yq9pLz2mXwK7vNc4tRj0dQ8sYfB1oEuI6aG5CxTz+VkP...","nonce":"3xQpZ8LwYtNc0Xj7","tag":"kR3xVv9m1sQpZ2f8Lw6Yt4="}

Your CreateOrderRequest/CreateOrderResponse JSON never appears in cleartext; the delegating handler swaps request.Content for the envelope before it reaches the socket, and reverses it on the response before your code sees it.

Error responses are the one exception, and are deliberately not encrypted, see "Handling errors" above:

HTTP/1.1 401 Unauthorized
Content-Type: application/problem+json

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.2","title":"Unauthorized","status":401,"detail":"Invalid or expired token."}

What an observer on the wire gets: ClientId, timestamp, nonce, signature, the opaque bearer JWT, AppOrigin, and the shape/size of each envelope. What they don't get: the Secret, the HKDF-derived AES key, or any plaintext request/response body, including the token response itself.

Configuration

Bind under the ACYTECHmacClient section. Every value here must match what the host has registered for this client id.

{
  "ACYTECHmacClient": {
    "BaseAddress": "https://userapi.acytec.lk",
    "ClientId": "evcrm",
    "Secret": "<from key vault, at least 32 bytes, generated by a CSPRNG>",
    "AppOrigin": "https://evcrm.acytec.lk",
    "TokenEndpointPath": "/auth/token",
    "EnablePayloadEncryption": true
  }
}
Option Type Default Description
BaseAddress string (required) Base address of the host API.
ClientId string (required) This client's id, as registered on the host.
Secret string (required) This client's shared secret. See "Client secret requirements" below. Source this from Key Vault, never commit a real value.
AppOrigin string (required) Sent as X-App-Origin on every request. Must match the AllowedOrigin the host registered for this client id.
TokenEndpointPath string /auth/token Must match the host's TokenEndpointPath.
EnablePayloadEncryption bool true Must match the host's EnablePayloadEncryption for this client. Covers the token response too: with it false, /auth/token returns plain { "accessToken": "...", "expiresAt": "..." } and this client reads it as-is instead of attempting to decrypt an envelope.
TokenRefreshBuffer TimeSpan 00:00:30 How long before actual expiry the client proactively refreshes the token.
ClientIdHeader / TimestampHeader / NonceHeader / SignatureHeader string X-Client-Id / X-Timestamp / X-Nonce / X-Signature Must match the host's HmacOptions header names.
AppOriginHeader string X-App-Origin Must match the host's declared-origin header name.

Source Secret from the Key Vault configuration provider so it never lives in appsettings.json.

Client secret requirements

Secret must be at least 32 bytes, generated by a CSPRNG, not a human chosen phrase. It does two jobs on the wire: it's the HMAC-SHA256 key that signs the token request, and it's the HKDF input keying material the AES-256 payload encryption key is derived from, so a weak secret weakens both.

This isn't just a recommendation, the host enforces it: ACYTEC.Security.Hmac.AspNetCore refuses to start if any registered client's secret is under strength, and separately rejects it at request time as if the client were unregistered. A Secret that doesn't meet this bar will authenticate against neither.

Generate one the same way you'd generate Tokens.SigningKey on the host:

$bytes = New-Object byte[] 32
[Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes)
[Convert]::ToBase64String($bytes)

Not Get-Random, it isn't cryptographically secure.

What it doesn't do

  • It doesn't retry failed calls or apply resiliency policies. Chain your own Polly handler onto the ACYTECHmacClientOptions.HttpClientName client via IHttpClientBuilder if you need that.
  • It doesn't expose a typed client (AddHttpClient<TClient, TImplementation> style). Use IHttpClientFactory.CreateClient(ACYTECHmacClientOptions.HttpClientName) directly.
  • It doesn't inspect or transform error responses. A non-2xx response is returned exactly as the host sent it, see "Handling errors" above.

See PROTOCOL.md (in the ACYTEC.Security.Hmac.AspNetCore repository) for the full wire level signing and encryption schemes this package implements, including a worked test vector you can use to verify a from-scratch reimplementation, e.g. in JavaScript.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.2 40 9/1/2026
1.0.1 29 9/1/2026