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
<PackageReference Include="ACYTEC.Security.Hmac.AspNetCore" Version="1.0.0" />
<PackageVersion Include="ACYTEC.Security.Hmac.AspNetCore" Version="1.0.0" />
<PackageReference Include="ACYTEC.Security.Hmac.AspNetCore" />
paket add ACYTEC.Security.Hmac.AspNetCore --version 1.0.0
#r "nuget: ACYTEC.Security.Hmac.AspNetCore, 1.0.0"
#:package ACYTEC.Security.Hmac.AspNetCore@1.0.0
#addin nuget:?package=ACYTEC.Security.Hmac.AspNetCore&version=1.0.0
#tool nuget:?package=ACYTEC.Security.Hmac.AspNetCore&version=1.0.0
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
- A caller signs a request to the token endpoint (
/auth/tokenby default) with a shared secret, an HMAC signature, a timestamp, and a nonce.HmacSignatureMiddlewarevalidates all of this before the request reaches token issuance. - 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.
- Every subsequent request uses that JWT as a bearer token instead of being
HMAC signed.
OriginValidationMiddlewarechecks the token's origin claim against the caller's declared origin as defense in depth. - 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
2xxresponses 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. SeePROTOCOL.mdfor 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.SigningKeyis 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 Requestinstead of an unhandled exception. - Only
2xxresponses 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 throughPayloadEncryptionMiddleware, it builds its own envelope. SettingEnablePayloadEncryptiontofalsedisables 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 configuredClients:{id}:Secretis 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 inConfigurationClientRegistry, 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,
ConfigurationClientRegistryre-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 | Versions 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. |
-
net10.0
- Microsoft.AspNetCore.Authentication.JwtBearer (>= 10.0.10)
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 |