ACYTEC.Security.Hmac.AspNetCore 1.0.0

dotnet add package ACYTEC.Security.Hmac.AspNetCore --version 1.0.0
                    
NuGet\Install-Package ACYTEC.Security.Hmac.AspNetCore -Version 1.0.0
                    
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.AspNetCore" Version="1.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="ACYTEC.Security.Hmac.AspNetCore" Version="1.0.0" />
                    
Directory.Packages.props
<PackageReference Include="ACYTEC.Security.Hmac.AspNetCore" />
                    
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.AspNetCore --version 1.0.0
                    
#r "nuget: ACYTEC.Security.Hmac.AspNetCore, 1.0.0"
                    
#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.AspNetCore@1.0.0
                    
#: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.AspNetCore&version=1.0.0
                    
Install as a Cake Addin
#tool nuget:?package=ACYTEC.Security.Hmac.AspNetCore&version=1.0.0
                    
Install as a Cake Tool

ACYTEC.Security.Hmac.AspNetCore

Full endpoint protection for a ACYTEC Group API acting as a host or resource server: HMAC signature validation on the token endpoint, self issued short lived JWTs, origin validation, and AES-GCM payload encryption. Wire it up with two lines in Program.cs instead of copying middleware between repos.

This package covers the host side only. Calling apps, MVC, API, and Function backends, use the companion ACYTEC.Security.Hmac.Client package (a separate package, in a separate repository) to talk to a host protected by this one, without hand-rolling the signing or encryption.

Install

dotnet add package ACYTEC.Security.Hmac.AspNetCore

Quick start

// Program.cs in a consuming host app
builder.Services.AddACYTECHmacSecurity(builder.Configuration);

var app = builder.Build();
app.UseACYTECHmacSecurity();

app.MapGet("/users/search", (string query) => Results.Ok(/* ... */))
   .RequireAuthorization(policy => policy.RequireClaim("scope", "UserApi.Search"));

app.Run();

Authorization policies stay the consuming app's responsibility, scope names differ per API. This package only handles authentication, token issuance, signature validation, origin checking, and payload encryption.

Do not call app.UseAuthorization() yourself. UseACYTECHmacSecurity() already calls it as part of wiring up the pipeline. Calling it a second time is a common ASP.NET Core mistake: it does not error immediately, but it silently breaks the moment a second policy check is added anywhere in the app.

How a request flows

  1. A caller signs a request to the token endpoint (/auth/token by default) with a shared secret, an HMAC signature, a timestamp, and a nonce. HmacSignatureMiddleware validates all of this before the request reaches token issuance.
  2. The token endpoint issues a short lived JWT containing the caller's scopes and allowed origin, encrypted in an AES-GCM envelope only that caller can decrypt.
  3. Every subsequent request uses that JWT as a bearer token instead of being HMAC signed. OriginValidationMiddleware checks the token's origin claim against the caller's declared origin as defense in depth.
  4. Unless disabled, request and response JSON bodies on authenticated routes are transparently encrypted and decrypted with a key derived from the caller's own secret. Only 2xx responses are encrypted — error responses (401, 400, 404, 500, ...) are always plain JSON, so a caller can read an error without first knowing whether the call succeeded. See PROTOCOL.md for the exact contract.

Usage examples

Multiple scopes on one endpoint

app.MapGet("/users/{id}", (int id) => Results.Ok(/* ... */))
   .RequireAuthorization(policy => policy.RequireClaim("scope", "UserApi.Read", "UserApi.Admin"));

RequireClaim("scope", ...) passes if the token has any of the listed scope claims. Combine policies for "must have all of" requirements the same way you would with any other ASP.NET Core claims-based policy.

MVC controllers, not just minimal APIs

UseACYTECHmacSecurity() sets up standard ASP.NET Core authentication and authorization middleware, so it works identically with controllers:

[ApiController]
[Route("orders")]
public sealed class OrdersController : ControllerBase
{
    [HttpPost]
    [Authorize(Policy = "UserApi.Orders.Create")] // policy defined by the consuming app
    public IActionResult Create(CreateOrderRequest request) => Ok(/* ... */);
}

Enabling or disabling payload encryption

EnablePayloadEncryption (default true) is a single switch covering both the token endpoint's response and every other authenticated request and response body. Set it to false if an API only ever handles non-sensitive data and you'd rather skip the AES-GCM envelope while keeping HMAC signing, token issuance, and origin validation:

{
  "ACYTECHmacSecurity": {
    "EnablePayloadEncryption": false
  }
}

With it false, the token endpoint returns plain { "accessToken": "...", "expiresAt": "..." } JSON instead of an encrypted envelope, and PayloadEncryptionMiddleware/PayloadDecryptionMiddleware don't run at all. This is also the easiest way to exercise the API from a tool that can't do AES-GCM, Postman included, see below.

The corresponding client must set the same flag, ACYTECHmacClient:EnablePayloadEncryption: false, or the two sides disagree about whether a body is wrapped in an envelope.

Handling errors

Only successful responses are ever encrypted. Write your endpoints exactly as you would without this package — Results.BadRequest(...), Results.NotFound(), throwing to a global exception handler, all reach the caller as plain, readable JSON:

app.MapPost("/orders", (CreateOrderRequest request) =>
{
    if (request.Quantity <= 0)
    {
        return Results.BadRequest(new { title = "Quantity must be positive" });
    }

    return Results.Ok(new { orderId = Guid.NewGuid() });
}).RequireAuthorization(policy => policy.RequireClaim("scope", "UserApi.Orders.Create"));

A caller (using ACYTEC.Security.Hmac.Client) sees this 400 and its body exactly as written, with no decryption step involved.

Manually testing the token endpoint

Useful when you don't have a client app handy yet. This computes the same canonical string and signature the middleware expects (see PROTOCOL.md):

$clientId = "evcrm"
$secret   = "<the client's configured Secret>"
$method   = "POST"
$path     = "/auth/token"
$timestamp = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()
$nonce     = [Guid]::NewGuid().ToString("N")

$bodyHash = [BitConverter]::ToString(
    [Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes(""))
).Replace("-", "").ToLower()

$canonical = ($method, $path, $clientId, $timestamp, $nonce, $bodyHash) -join "`n"

$hmac = New-Object Security.Cryptography.HMACSHA256
$hmac.Key = [Text.Encoding]::UTF8.GetBytes($secret)
$signature = [Convert]::ToBase64String($hmac.ComputeHash([Text.Encoding]::UTF8.GetBytes($canonical)))

Invoke-WebRequest -Uri "http://localhost:5289/auth/token" -Method Post -UseBasicParsing -Headers @{
    "X-Client-Id"  = $clientId
    "X-Timestamp"  = $timestamp
    "X-Nonce"      = $nonce
    "X-Signature"  = $signature
}

The response body is an AES-GCM envelope; decrypt it with the same PayloadCipher scheme documented in PROTOCOL.md to read the issued token. samples/SampleClientApp in the client package's repository does all of this for you.

Testing with Postman

Postman's pre-request script sandbox bundles CryptoJS, which handles the HMAC-SHA256 signing fine, but not AES-GCM, CryptoJS doesn't include GCM mode and Postman has no other crypto API you can rely on for it. Set EnablePayloadEncryption to false (see above) while testing in Postman so neither side ever needs to encrypt or decrypt anything; if you specifically need to test with encryption on, decrypt outside Postman with one of the sample console apps instead.

Environment variables:

Variable Example
baseUrl http://localhost:5289
tokenPath /auth/token
clientId evcrm
clientSecret the client's configured Secret
appOrigin the client's configured AllowedOrigin

Request "Get Token", POST {{baseUrl}}{{tokenPath}}, empty body.

Pre-request Script:

const method = "POST";
const path = pm.environment.get("tokenPath");
const clientId = pm.environment.get("clientId");
const secret = pm.environment.get("clientSecret");

const timestamp = Math.floor(Date.now() / 1000).toString();
const nonce = CryptoJS.lib.WordArray.random(16).toString();
const bodyHash = CryptoJS.SHA256("").toString(CryptoJS.enc.Hex); // empty body

const canonical = [method, path, clientId, timestamp, nonce, bodyHash].join("\n");
const signature = CryptoJS.HmacSHA256(canonical, secret).toString(CryptoJS.enc.Base64);

pm.request.headers.upsert({ key: "X-Client-Id", value: clientId });
pm.request.headers.upsert({ key: "X-Timestamp", value: timestamp });
pm.request.headers.upsert({ key: "X-Nonce", value: nonce });
pm.request.headers.upsert({ key: "X-Signature", value: signature });

Tests script, saves the token for reuse:

pm.test("Status is 200", () => pm.response.to.have.status(200));
const json = pm.response.json();
pm.environment.set("accessToken", json.accessToken);

Any protected request: Auth tab → Bearer Token → {{accessToken}}, plus a header X-App-Origin: {{appOrigin}}. No signing needed, this only applies to the token endpoint.

Troubleshooting invalid_signature: tokenPath must match the literal request path the server sees, byte for byte, including the leading / (the server signs against HttpContext.Request.Path, which always starts with one). auth/token and /auth/token produce different canonical strings and therefore different signatures. If you're using pm.variables.get/.set instead of pm.environment.get/.set (works for both environment and collection variables), that's fine too, they just need to resolve to the same values either way. A console.log(JSON.stringify(canonical)) right after building it is the fastest way to spot a stray character. The "Using CryptoJS is deprecated" warning in the Postman console is expected and harmless, CryptoJS still works.

Reliability notes

  • Fails fast at startup if Tokens.SigningKey is missing or shorter than 32 bytes, with a clear configuration error, instead of throwing from deep inside the JWT bearer handler on the first authenticated request.
  • Malformed or tampered request envelopes never crash the app. Invalid JSON, non-base64 fields, a wrong-length nonce or tag, or a failed authentication tag check, all produce a clean 400 Bad Request instead of an unhandled exception.
  • Only 2xx responses are encrypted, so error handling on both sides of the wire is symmetric: check the status code first, only decrypt on success.
  • The token endpoint honors EnablePayloadEncryption. It's the one response that would otherwise always be encrypted regardless of the flag, since it doesn't go through PayloadEncryptionMiddleware, it builds its own envelope. Setting EnablePayloadEncryption to false disables that too, so the flag is a single, honest switch rather than something that quietly still encrypts one response.
  • Client secrets are held to the same bar as Tokens.SigningKey. Every configured Clients:{id}:Secret is validated at startup, and the app refuses to start if any is missing or shorter than 32 bytes, naming every offending client id in the error. A client added or changed after startup (a live configuration reload, for example) gets the same check at lookup time in ConfigurationClientRegistry, treated as unknown until fixed rather than silently accepted. See "Client secret requirements" below for why this matters.

Options reference

Bind under the ACYTECHmacSecurity configuration section.

Option Type Default Description
Hmac.MaxClockSkew TimeSpan 00:02:00 Max allowed difference between the caller's timestamp and server time.
Hmac.NonceRetention TimeSpan 00:05:00 How long a nonce is remembered to block replay.
Hmac.ClientIdHeader string X-Client-Id Header carrying the caller's client id.
Hmac.TimestampHeader string X-Timestamp Header carrying the request's Unix timestamp, in seconds.
Hmac.NonceHeader string X-Nonce Header carrying the request's single-use nonce.
Hmac.SignatureHeader string X-Signature Header carrying the request's HMAC signature.
Tokens.Issuer string ACYTEC-user-api Issuer and audience baked into issued tokens.
Tokens.SigningKey string (empty) Symmetric signing key, at least 32 bytes. Source this from Key Vault, never commit a real value. The app refuses to start without one.
Tokens.Lifetime TimeSpan 00:30:00 How long an issued token remains valid.
TokenEndpointPath string /auth/token Route the token endpoint is mapped to.
EnablePayloadEncryption bool true Set to false to disable AES-GCM encryption everywhere it applies, the token endpoint's response included, while keeping HMAC signing, token issuance, and origin validation.

Client registrations live at the configuration root, under Clients:{id}, shared with other tooling, not nested under ACYTECHmacSecurity.

Client secret requirements

A client's Secret does two jobs, it is the HMAC-SHA256 key that signs requests to the token endpoint, and it is the HKDF input keying material the AES-256 payload encryption key is derived from. A weak secret weakens both.

Minimum 32 bytes, generated by a CSPRNG, not a human chosen phrase. Enforced, not just documented:

  • At startup, every configured client's secret is checked. The app refuses to start if any is missing or under strength, naming every offending client id.
  • At lookup time, ConfigurationClientRegistry re-checks it and treats a client with an under strength secret as unknown, the same as a client id that was never registered, rather than authenticating with a weak key.

Generate one, for example:

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

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

Full configuration shape

{
  "ACYTECHmacSecurity": {
    "Hmac": {
      "MaxClockSkew": "00:02:00",
      "NonceRetention": "00:05:00"
    },
    "Tokens": {
      "Issuer": "ACYTEC-user-api",
      "SigningKey": "<from key vault, at least 32 bytes>",
      "Lifetime": "00:30:00"
    },
    "TokenEndpointPath": "/auth/token",
    "EnablePayloadEncryption": true
  },
  "Clients": {
    "evcrm": {
      "Secret": "<from key vault, at least 32 bytes, generated by a CSPRNG>",
      "AllowedOrigin": "https://evcrm.acytec.lk",
      "Scopes": ["UserApi.Search", "UserApi.Read"]
    },
    "callcentre": {
      "Secret": "<from key vault, at least 32 bytes, generated by a CSPRNG>",
      "AllowedOrigin": "https://callcentre.acytec.lk",
      "Scopes": ["UserApi.Read"]
    }
  }
}

Back every Clients:{id}:Secret and ACYTECHmacSecurity:Tokens:SigningKey with the Key Vault configuration provider so secrets never live in appsettings.json.

See samples/SampleHostApi in this repository for a minimal, runnable host, and PROTOCOL.md for the wire level HMAC signing and payload encryption schemes, including a worked test vector.

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 (1)

Showing the top 1 NuGet packages that depend on ACYTEC.Security.Hmac.AspNetCore:

Package Downloads
ACYTEC.Security.Hmac.AspNetCore.Functions

Azure Functions isolated-worker adapter for ACYTEC.Security.Hmac.AspNetCore. Adds a Functions-native token endpoint and pipeline middleware so a Functions app gets the same HMAC signing, JWT auth, origin validation, and AES-GCM payload encryption as an ASP.NET Core host, without pulling Functions Worker dependencies into the base package.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.0 35 9/1/2026