API2 Machine-to-Machine Authentication: API Key Design, HMAC-Signed Requests and Protecting Token Endpoints
A machine-to-machine API key is designed with a recognisable prefix, stored only as a hash, bound to a tenant and scopes, rotated with two active keys and revocable, and it never travels in the query string. We build a custom authentication handler, HMAC-signed requests with a timestamp and body hash, replay protection, and per-IP and per-account limits on endpoints such as password reset, all with tests.
Version note: The code in this post was built and run with .NET 10 and EF (Entity Framework) Core 10.0.14.
Guess First
Bookstore's North store wants to share its orders with a shipping company. The shipping company will pull new orders every hour and send back the tracking number once a parcel is on its way. Someone on the team says: "Let's generate an API (Application Programming Interface) key, write it to the database, and they can call us with ?api_key=...." How many separate problems are in that sentence?
I count four. The key is stored in plain text, it travels in the query string, nothing says which tenant and which operations it is bound to, and nobody has thought about what happens when it must be replaced. A fifth can come on top: how will we know that the request sending the tracking number wasn't modified on the way or replayed?
This post is the machine-to-machine side of API2 (Broken Authentication in the OWASP API Security Top 10 2023). User login, brute force and lockout basics are in the A07 Authentication Failures post of the Web Security (OWASP Top 10) series; OAuth and token validation belong to the upcoming Authentication and Authorization series. Here we focus on API key design for B2B (Business to Business) integrations, HMAC (Hash-based Message Authentication Code) signed requests and hardening authentication-related endpoints.
When Is an API Key the Right Tool?
The OWASP (Open Worldwide Application Security Project) API2 page draws a clear line: API keys should be used only to authenticate API clients, not users. An API key says "this request comes from the ParcelCo integration"; it doesn't say "this request comes from Alan". There is no human, no session and no MFA (Multi-Factor Authentication) step behind it.
The second question is API key versus OAuth 2.0 client credentials. If you have an authorization server and the other side knows how to obtain tokens, client credentials is the better choice: short-lived access tokens, standard validation, central revocation. But in many B2B integrations the other side is a small company with a cron job and a curl command. In that case a well-designed API key is safer than a badly implemented OAuth flow. In this post we build the "well-designed" part; client credentials and DPoP (Demonstrating Proof of Possession) will come in the post about protecting the API in the Authentication and Authorization series.
A Recognisable Key Format
The first decision is the format itself. Our key will look like this: bks_dev_6lu2rqhriac2_D9Vs9VK6.... It has three parts and each has a job:
// bks_dev_<keyId>_<secret>: the prefix is recognisable (secret scanning), keyId is for lookup, the secret is stored only as a hash
public static class ApiKeyFormat
{
public const string Prefix = "bks_dev_";
private const string KeyIdAlphabet = "abcdefghijklmnopqrstuvwxyz234567";
public static (string Key, string KeyId, byte[] SecretHash) Generate()
{
string keyId = RandomNumberGenerator.GetString(KeyIdAlphabet, 12);
string secret = Base64UrlEncode(RandomNumberGenerator.GetBytes(32));
return ($"{Prefix}{keyId}_{secret}", keyId, Hash(secret));
}
public static bool TryParse(string key, out string keyId, out string secret)
{
keyId = secret = "";
if (!key.StartsWith(Prefix, StringComparison.Ordinal) || key.Length != Prefix.Length + 12 + 1 + 43
|| key[Prefix.Length + 12] != '_')
{
return false;
}
keyId = key.Substring(Prefix.Length, 12);
secret = key[(Prefix.Length + 13)..];
return true;
}
// The secret is 256 random bits: no need for a slow password hash, SHA-256 is enough
public static byte[] Hash(string secret) => SHA256.HashData(System.Text.Encoding.ASCII.GetBytes(secret));
private static string Base64UrlEncode(byte[] bytes) =>
Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
The prefix makes the key recognisable when it shows up in a log file, a commit or a Slack message. The ghp_ prefix on GitHub's tokens serves the same purpose: secret scanning tools look for that prefix and can catch a leaked key. Putting the environment in the prefix too (dev, another value in production) makes a key pasted into the wrong environment obvious at first glance.
The keyId isn't secret; it's an indexed column we use to find the key in the database. The secret is 32 random bytes, i.e. 256 bits. The reason I use SHA-256, from the SHA (Secure Hash Algorithm) family, for the hash is that entropy: passwords need slow hashes such as PBKDF2 (Password-Based Key Derivation Function 2) or Argon2 because the passwords people choose are guessable. Brute-forcing a 256-bit random value is impossible however fast the hash is. We covered password storage in the A04 Cryptographic Failures post of the Web Security series.
Storing Only the Hash and the Authentication Handler
The database doesn't hold the key itself; only KeyId, SecretHash, scopes, tenant and timestamps. Validation is a small class that fits ASP.NET Core's authentication handler model:
public sealed class ApiKeyAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder,
ApiKeyStore store, TimeProvider time)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
public const string SchemeName = "ApiKey";
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
// A key in the query string ends up in logs, proxies and browser history: reject it even if valid
if (Request.Query.Keys.Any(k => k.Equals("api_key", StringComparison.OrdinalIgnoreCase)
|| k.Equals("apikey", StringComparison.OrdinalIgnoreCase)))
{
return AuthenticateResult.Fail("API keys are not accepted in the query string.");
}
string? header = Request.Headers.Authorization;
if (header is null || !header.StartsWith("ApiKey ", StringComparison.Ordinal))
{
return AuthenticateResult.NoResult();
}
if (!ApiKeyFormat.TryParse(header["ApiKey ".Length..].Trim(), out string keyId, out string secret))
{
return AuthenticateResult.Fail("Malformed API key.");
}
ApiKey? key = await store.FindAsync(keyId, Context.RequestAborted);
DateTime now = time.GetUtcNow().UtcDateTime;
if (key is null || !CryptographicOperations.FixedTimeEquals(key.SecretHash, ApiKeyFormat.Hash(secret))
|| key.RevokedAt is not null || key.ExpiresAt <= now)
{
// Only the keyId goes to the log, never the key itself
Logger.LogWarning("API key authentication failed for key id {KeyId}", keyId);
return AuthenticateResult.Fail("Invalid API key.");
}
await store.TouchAsync(key.Id, now, Context.RequestAborted);
List<Claim> claims =
[
new(LabClaims.Subject, $"client:{key.Id}"),
new(LabClaims.Tenant, key.TenantId),
new(LabClaims.Role, "integration"),
new("key_id", key.KeyId)
];
claims.AddRange(key.Scopes.Split(' ').Select(scope => new Claim("scope", scope)));
ClaimsIdentity identity = new(claims, SchemeName, LabClaims.Subject, LabClaims.Role);
return AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), SchemeName));
}
}
Let's go through a few decisions. The comparison uses CryptographicOperations.FixedTimeEquals; the byte at which two hashes differ can't be read from the response time. For a hash comparison that risk is small anyway, but it's a cheap habit. Key not found, hash mismatch, revoked or expired: all four cases return the same generic failure. Telling the client "this key was revoked" confirms to someone holding an old key that it was once valid. Only the keyId goes to the log; the key itself never lands in any log line.
ApiKeyStore deliberately runs unscoped: when a key is being validated, the tenant isn't known yet; we learn the tenant from the key itself. In the previous post we wrote the test that confines IgnoreQueryFilters() calls to a single file; this file was added to that test's allowlist as the second and last entry, with its reason.
Scopes, Tenants and Policies
The principal produced by the handler has a tenant_id claim and the role integration. That means the DataScope we built in the previous post works unchanged for requests that come with a key: if the shipping company's key is bound to North, queries are limited to North's data. The policies require both the scheme and the scope:
// Integration endpoints accept only API keys; a user JWT doesn't get through here
.AddPolicy(IntegrationPolicies.OrdersRead, policy => policy
.AddAuthenticationSchemes(ApiKeyAuthenticationHandler.SchemeName)
.RequireRole("integration")
.RequireClaim("scope", "orders.read"))
.AddPolicy(IntegrationPolicies.ShipmentsWrite, policy => policy
.AddAuthenticationSchemes(ApiKeyAuthenticationHandler.SchemeName)
.RequireRole("integration")
.RequireClaim("scope", "shipments.write"));
AddAuthenticationSchemes is critical. Without it, JwtBearer as the default scheme would run, and a customer's JWT (JSON Web Token) would get all the way to the integration endpoints' door, even if the role didn't match. Now integration endpoints recognise only API keys. Let's generate a key with Ada's JWT and try it (I shortened the key and the signing secret in the post):
$ curl -s -X POST http://localhost:19513/admin/api-keys -H "Authorization: Bearer $ADA" -H "Content-Type: application/json" -d '{"clientName":"parcelco","scopes":["orders.read","shipments.write"]}'
{"id":"01a1b9ad-398f-7c70-9869-48fcd1f748c2","apiKey":"bks_dev_6lu2rqhriac2_D9Vs9VK6...","signingSecret":"ivWaadvx...","expiresAt":"2027-02-06T05:41:39.829771Z"}
$ curl -s http://localhost:19513/integrations/orders -H "Authorization: ApiKey $KEY"
[{"id":"0197f001-0000-7000-8000-000000000001","status":"Paid","total":54.9,"createdAt":"2026-11-02T10:00:00"},{"id":"0197f001-0000-7000-8000-000000000002","status":"Pending","total":54.9,"createdAt":"2026-11-02T10:00:00"}]
$ curl -i http://localhost:19513/integrations/orders -H "Authorization: Bearer $ALAN"
HTTP/1.1 401 Unauthorized
Content-Length: 0
Date: Sun, 08 Nov 2026 05:41:39 GMT
Server: Kestrel
$ curl -i "http://localhost:19513/integrations/orders?api_key=$KEY"
HTTP/1.1 401 Unauthorized
Content-Length: 0
Date: Sun, 08 Nov 2026 05:41:39 GMT
Server: Kestrel
The key saw only North's two orders; Linus's order isn't in the list, and we didn't write a single line of tenant code in the integration endpoint for that. Alan's JWT got 401. In the last request the key is in the query string: it was rejected even though it's a valid key. Sending tokens or passwords in the URL (Uniform Resource Locator) is a separate item on OWASP API2's "is the API vulnerable?" list. URLs land in web server logs, reverse proxies, APM (Application Performance Monitoring) tools and the Referer header; headers, on the other hand, aren't logged by default in most places. Rejecting instead of silently accepting puts whoever writes the integration on the right path from the first attempt.
Rotation, Revocation and Last-Used Time
A key's life ends with one of three events: it expires, it is revoked or it is replaced. The third is the most neglected. In a system that allows a single active key, replacing a key means downtime: the moment a new key is generated the old one dies, and requests get 401 until the other side deploys the new key. That's why we allow two active keys per client:
DateTime now = time.GetUtcNow().UtcDateTime;
// At most two active keys per client for rotation: the old one keeps working while the new one is rolled out
int active = await db.ApiKeys.CountAsync(k => k.ClientName == request.ClientName
&& k.RevokedAt == null && k.ExpiresAt > now, ct);
if (active >= IntegrationPolicies.MaxActiveKeysPerClient)
{
return Results.Problem(statusCode: StatusCodes.Status409Conflict,
title: "Revoke an existing key before creating a new one.");
}
The flow is: a second key is generated and handed over, the other side switches, and once LastUsedAt shows the old key is no longer used, the old key is revoked. Asking for a third key gets 409; that bakes the "don't create a new key before cleaning up old ones" discipline into the system. Every key dies on its own after 90 days. Revocation and what follows:
$ curl -s -X DELETE http://localhost:19513/admin/api-keys/01a1b9ad-398f-7c70-9869-48fcd1f748c2 -H "Authorization: Bearer $ADA" -o /dev/null -w "%{http_code}\n"
204
$ curl -s http://localhost:19513/integrations/orders -H "Authorization: ApiKey $KEY" -o /dev/null -w "%{http_code}\n"
401
$ curl -s http://localhost:19513/admin/api-keys -H "Authorization: Bearer $ADA"
[{"id":"01a1b9ad-398f-7c70-9869-48fcd1f748c2","clientName":"parcelco","keyId":"6lu2rqhriac2","scopes":"orders.read shipments.write","expiresAt":"2027-02-06T05:41:39.829771","revokedAt":"2026-11-08T05:41:40.043018","lastUsedAt":"2026-11-08T05:41:40.033007"}]
The listing endpoint returns neither the key nor the hash. The key is visible only in the response that created it; if lost, it isn't recovered, a new one is generated. LastUsedAt is there so revocation decisions are based on data: "who uses this key, what breaks if I close it?" is answered here. Validation updates this column on every request; under very high traffic it makes sense to limit that to once a minute.
HMAC-Signed Requests
An API key proves "who you are" but says nothing about the content of the request. TLS (Transport Layer Security) protects the path, but whatever lies after TLS ends, such as a corporate proxy, a log collector or the other side's own infrastructure, can see and record the request. Replaying a recorded request later or changing the tracking number in it only requires knowing the key. We don't want that on an endpoint that changes shipment state.
The signed text consists of these four parts:
// The signed text: time, method, path+query and the body's SHA-256; change any of them and the signature fails
public static string Canonical(long timestamp, string method, string pathAndQuery, byte[] body) =>
$"{timestamp}\n{method}\n{pathAndQuery}\n{Convert.ToHexStringLower(SHA256.HashData(body))}";
public static string Sign(byte[] secret, string canonical) =>
"v1=" + Convert.ToHexStringLower(HMACSHA256.HashData(secret, Encoding.UTF8.GetBytes(canonical)));
HMAC needs a secret both sides know, and there is a tension here: we store the API key only as a hash, so the server doesn't know the key itself and can't use it as the HMAC secret. My solution is to derive a separate signing secret per key from a single master key on the server: HMACSHA256(master, "signing:" + keyId). The secret isn't stored anywhere, it's recomputed when needed and given to the client once when the key is created. The price is that the master key becomes very valuable; in production it should live in a KMS (Key Management Service) or Key Vault.
Validation is an endpoint filter. It's attached to an endpoint that doesn't bind the body as a parameter, because we don't want the body interpreted before the signature is verified:
public sealed class RequestSignatureFilter(SigningKeys keys, TimeProvider time, IMemoryCache seen,
ILogger<RequestSignatureFilter> logger) : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
HttpRequest request = context.HttpContext.Request;
string? keyId = context.HttpContext.User.FindFirstValue("key_id");
string signature = request.Headers[RequestSignature.SignatureHeader].ToString();
if (keyId is null || signature.Length == 0
|| !long.TryParse(request.Headers[RequestSignature.TimestampHeader], out long timestamp))
{
return Reject("missing signature headers");
}
// Clock skew is tolerated, but a request outside the window counts as replayed
DateTimeOffset sentAt = DateTimeOffset.FromUnixTimeSeconds(timestamp);
if ((time.GetUtcNow() - sentAt).Duration() > RequestSignature.Window)
{
return Reject("timestamp outside the window");
}
request.EnableBuffering();
using MemoryStream buffer = new();
await request.Body.CopyToAsync(buffer, context.HttpContext.RequestAborted);
request.Body.Position = 0;
string canonical = RequestSignature.Canonical(timestamp, request.Method,
request.Path + request.QueryString, buffer.ToArray());
string expected = RequestSignature.Sign(keys.For(keyId), canonical);
if (!CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(signature)))
{
return Reject("signature mismatch");
}
// The same signature arriving twice within the window is a replay
if (!seen.TryGetValue(signature, out _))
{
seen.Set(signature, true, RequestSignature.Window * 2);
return await next(context);
}
return Reject("replayed signature");
IResult Reject(string reason)
{
logger.LogWarning("Rejected signed request from key id {KeyId}: {Reason}", keyId, reason);
return Results.Problem(statusCode: StatusCodes.Status401Unauthorized, title: "Invalid request signature.");
}
}
}
The timestamp window is five minutes: it tolerates small clock differences between client and server, but prevents yesterday's request from being replayed. Replays within the window are caught by a cache that keeps seen signatures for twice the window. I added a signing command to the lab's TokenTool; a real client writes the same four lines in its own language:
$ dotnet run --project tools/Bookstore.Orders.TokenTool -- sign $SECRET POST /integrations/orders/0197f001-0000-7000-8000-000000000001/shipments shipment.json > sig.txt
$ cat sig.txt
X-Bks-Timestamp: 1794116500
X-Bks-Signature: v1=48b6636879f8d93f661d6894a366a3dd06c3e68746918c1aed58c8da3801f80e
$ curl -i -X POST http://localhost:19513/integrations/orders/0197f001-0000-7000-8000-000000000001/shipments -H "Authorization: ApiKey $KEY" -H @sig.txt -H "Content-Type: application/json" --data-binary @shipment.json
HTTP/1.1 204 No Content
Date: Sun, 08 Nov 2026 05:41:39 GMT
Server: Kestrel
$ curl -i -X POST http://localhost:19513/integrations/orders/0197f001-0000-7000-8000-000000000001/shipments -H "Authorization: ApiKey $KEY" -H @sig.txt -H "Content-Type: application/json" --data-binary @shipment.json
HTTP/1.1 401 Unauthorized
Content-Type: application/problem+json
Date: Sun, 08 Nov 2026 05:41:39 GMT
Server: Kestrel
Transfer-Encoding: chunked
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.2","title":"Invalid request signature.","status":401,"traceId":"00-1198a42818962c7a92866af0b4a17350-20ff6efc86755795-00"}
warn: Bookstore.Orders.Api.Integrations.RequestSignatureFilter[0]
Rejected signed request from key id 6lu2rqhriac2: replayed signature
The message returned to the client doesn't give the reason; the log does. The modified-body and stale-timestamp scenarios are in the tests too: Tampered_body_and_stale_timestamp_are_rejected.
Hardening Authentication Endpoints
OWASP API2 asks for endpoints like password reset to be protected as well as login; most teams put lockout on login and forget "forgot password". Yet the reset endpoint can be abused for two things: flooding users' inboxes and learning which emails are registered. We build two defences together: limits per IP (Internet Protocol) address and per account, plus the same response whether or not the account exists.
// Per-account limit: at most 3 reset requests per email in 15 minutes
private static readonly PartitionedRateLimiter<string> PerAccount = PartitionedRateLimiter.Create<string, string>(
email => RateLimitPartition.GetFixedWindowLimiter(email, _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 3,
Window = TimeSpan.FromMinutes(15),
QueueLimit = 0
}));
public static void MapAccountEndpoints(this IEndpointRouteBuilder app)
{
app.MapPost("/account/password-reset", (PasswordResetRequest request, HttpContext context,
ILogger<PasswordResetRequest> logger) =>
{
string account = request.Email.Trim().ToLowerInvariant();
using RateLimitLease lease = PerAccount.AttemptAcquire(account);
if (!lease.IsAcquired)
{
if (lease.TryGetMetadata(MetadataName.RetryAfter, out TimeSpan retryAfter))
{
context.Response.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString();
}
return Results.Problem(statusCode: StatusCodes.Status429TooManyRequests,
title: "Too many reset requests for this account.");
}
// Same response whether or not the account exists: nobody can verify an email list
logger.LogInformation("Password reset requested");
return Results.Accepted();
})
.AllowAnonymous()
.RequireRateLimiting(PerIpPolicy);
}
The IP limit lives in AddRateLimiter as a fixed window policy partitioned by RemoteIpAddress: 10 requests per minute. The account limit lives inside the endpoint, because its partition key is the email in the body and the rate limiting middleware doesn't read the body. The two limits complement each other: the IP limit slows down scanning from a single source, the account limit stops an attack spread across thousands of IPs from drowning a single user in emails. I leave the middleware's algorithms and chained limiters to the rate limiting post of the upcoming ASP.NET Core Security series.
$ for i in 1 2 3; do curl -s -o /dev/null -w "%{http_code} " -X POST http://localhost:19513/account/password-reset -H "Content-Type: application/json" -d '{"email":"alan@north.bookstore.test"}'; done
202 202 202
$ curl -i -X POST http://localhost:19513/account/password-reset -H "Content-Type: application/json" -d '{"email":"alan@north.bookstore.test"}'
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Date: Sun, 08 Nov 2026 05:41:39 GMT
Server: Kestrel
Retry-After: 900
Transfer-Encoding: chunked
{"title":"Too many reset requests for this account.","status":429,"traceId":"00-7a87cb9ca74117809b0d5b0fd0bff274-c74851f543b45b61-00"}
$ curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:19513/account/password-reset -H "Content-Type: application/json" -d '{"email":"nobody@north.bookstore.test"}'
202
An email that doesn't exist gets 202 too. And since the account limit works the same for existing and non-existing addresses, the 429 gives nothing away either. The same two-layer limit, per client and per IP, should be applied to a token endpoint if you have one.
Every claim in this post lives in one test class. I also added six rows to the matrix: requests to the integration endpoint with no credentials, a customer JWT and an admin JWT must get 401; key management must be open only to admins.
passed ApiKeyTests.Missing_scope_is_forbidden (563ms)
passed ApiKeyTests.Tampered_body_and_stale_timestamp_are_rejected (5ms)
passed ApiKeyTests.Only_the_hash_of_the_secret_is_stored (5ms)
passed ApiKeyTests.Third_active_key_for_the_same_client_is_a_conflict (1ms)
passed ApiKeyTests.Password_reset_is_limited_per_account_and_does_not_reveal_accounts (3ms)
passed ApiKeyTests.Key_sees_only_its_own_tenant (4ms)
passed ApiKeyTests.Revoked_key_is_rejected (2ms)
passed ApiKeyTests.Key_in_query_string_is_rejected_even_if_valid (3ms)
passed ApiKeyTests.Signed_shipment_is_accepted_once_and_replay_is_rejected (3ms)
Test run summary: Passed!
total: 56
failed: 0
succeeded: 56
skipped: 0
duration: 1s 211ms
Production Notes
The replay cache is IMemoryCache, i.e. specific to one instance. In an API running behind three instances, the same signature could land on different instances and be accepted three times within the window. In production, seen signatures should be kept in a shared store such as Redis with a TTL (Time To Live) of the window length. Alternatively, you can add a client-generated nonce to the signature and store the nonce in the database with a unique constraint.
Rotating the master key is a separate topic. Since derived secrets depend on the master key, changing it changes every client's signing secret. To make that manageable, you can tie the v1= version prefix in the signature header to the master key version and accept two master keys together for a while. That's why the signature carries a version field from the start.
Write down what you will do when a key leaks, in advance. Register the prefix as a custom pattern in GitHub secret scanning or in your own DLP (Data Loss Prevention) tool; revoking a leaked key within minutes is a more realistic goal than expecting it never to leak.
Monitor integration requests separately from user requests. If the key_id claim goes into the log scope, you can answer a question like "ParcelCo's key sent 4000 requests at 03:00 last night". Writing the source IP next to the keyId on authentication failures also makes scanning attempts visible.
Common Pitfalls
The first pitfall is thinking that storing the key encrypted is enough. An encrypted key is plain text to anyone who can reach the encryption key. For validation you don't need the key itself, you need its hash.
The second is using a key as a user's identity. Thinking "we gave the key to Alan, so this request is Alan's" mixes it up with personal API tokens. If you need personal tokens, issue short-lived, scoped tokens bound to the user; let an integration key represent a client.
The third is a single "full access" scope. The shipping company needs to read orders and report shipments; not to cancel orders or pull the customer list. Scopes should be designed around business capabilities rather than endpoints, and chosen from an allowlist when a key is created.
The fourth is writing the key or the signature to a log. Turning on request logging for the Authorization header, printing the whole request into an error message on the HTTP (Hypertext Transfer Protocol) client side, carrying the key in an exception message; all of these are leak paths seen in real incidents.
A Small Experiment
★☆☆ In the lab, generate a second key, call /integrations/orders with both keys, then try a third. After seeing the 409, revoke the old key and generate a new one.
★★☆ Add an X-Bks-Nonce header to the signature and tie replay protection to the keyId + nonce pair instead of the signature. What should happen when the same nonce arrives with a different body?
★★★ Limit the LastUsedAt update to once a minute and add an admin endpoint that lists keys unused for 30 days. Don't forget to write that endpoint's matrix rows.
In the next post we move to API3 (Broken Object Property Level Authorization): the same order looking different to support (with the cost field) and to the customer (without it), the writable-field allowlist for PATCH requests, and using the OpenAPI schema as a contract.