Archiboard

Set I · Identity & Access

API keys, client credentials and machine access

Customers integrate with a cron script long before they integrate with your SDK. Give the script a key, give the platform a token, and never store either in the clear.

A customer wanted a copy of every alert we had ever sent them, in their own data warehouse. This was at a regulatory news publisher, where the alert pipeline sends over a million mails a month, and their integration was a forty-line Python script run by cron every five minutes, written by their operations lead, who had never seen an OAuth library and did not intend to. He wanted a key to paste into a header. We gave him one in March 2020. In 2024 he told me the script was still running, unchanged, and that it was the most reliable thing in his warehouse.

That script is why this sheet has two paths. API keys are for scripts and the people who write them. Client credentials are for platforms and partners with an engineer on staff. The first mistake is treating one as the poor cousin of the other and offering only the grown-up one. The second mistake, which I have seen more often, is storing the key.

The drawing shows both paths ending in the same place: a ClaimsPrincipal with a tenant and a set of scopes, which is all the rest of the API ever sees.

The two machine paths into the API path A: API key customer script API key handler X-Api-Key key hashes id + hash principal: tenant + scopes path B: client credentials customer service token endpoint client_credentials access token JWT bearer handler Bearer jwt signing keys principal: client + scopes rate limiter, partitioned by key or client API endpoints only the hash lives here
Fig. 1. Two machine paths, one principal. The key path looks up a hash in your table; the token path validates a signature against the issuer's keys. Everything after the principal is shared.

Path A: the key you never store

A key is a credential with the entropy of a good password and none of the human problems, so treat it like one and not like a password. Ours look like ak_live_7f3a9c2e_<43 characters>: a recognisable prefix, an eight-character public id you can log and search on, and thirty-two random bytes encoded as base64url. The secret part is shown to the customer once, on the screen where it was created, and then it exists only as a SHA-256 hash in our table.

SHA-256 rather than a slow hash is deliberate. Slow hashing exists to protect low-entropy secrets from offline guessing; a 256-bit random value cannot be guessed offline, and a slow hash on every API call would cost more CPU than the endpoint behind it. What the table holds, next to the hash: key id, tenant id, scopes, created, expires, last used, revoked. Issuing one is a dozen lines on .NET 9:

public static (string Key, ApiKeyRecord Record) Issue(Guid tenantId, string[] scopes)
{
    var id = RandomNumberGenerator.GetHexString(8, lowercase: true);
    var secret = Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(32));
    var record = new ApiKeyRecord
    {
        KeyId = id,
        SecretHash = SHA256.HashData(Encoding.ASCII.GetBytes(secret)),
        TenantId = tenantId,
        Scopes = scopes,
        ExpiresAt = DateTimeOffset.UtcNow.AddDays(365)
    };
    return ($"ak_live_{id}_{secret}", record);
}

Authentication is a custom AuthenticationHandler<ApiKeyOptions>. The 2020 version on 3.1 had the same shape with more ceremony; the core of HandleAuthenticateAsync is this:

if (!Request.Headers.TryGetValue("X-Api-Key", out var raw))
    return AuthenticateResult.NoResult();

var parts = raw.ToString().Split('_');               // ak, live, id, secret
if (parts.Length != 4) return AuthenticateResult.Fail("malformed key");

var record = await keys.FindAsync(parts[2]);
if (record is null || record.RevokedAt is not null
    || record.ExpiresAt < TimeProvider.GetUtcNow())
    return AuthenticateResult.Fail("unknown or expired key");

var hash = SHA256.HashData(Encoding.ASCII.GetBytes(parts[3]));
if (!CryptographicOperations.FixedTimeEquals(hash, record.SecretHash))
    return AuthenticateResult.Fail("bad secret");

var identity = new ClaimsIdentity(Scheme.Name);
identity.AddClaim(new Claim("tenant", record.TenantId.ToString()));
identity.AddClaim(new Claim("key_id", record.KeyId));
foreach (var s in record.Scopes) identity.AddClaim(new Claim("scope", s));
return AuthenticateResult.Success(
    new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name));

Scopes are strings like documents:read and alerts:read, checked by a policy with RequireClaim("scope", ...). The tenant claim is the one the authorization layer (sheet I-03) already understands, so an API key request and a browser request go through the same handlers from here on. A key issued to acme can only ever produce an acme principal; there is no "which tenant" parameter for the script to get wrong.

Path B: client credentials, the grown-up option

When the caller is a platform rather than a script, an ERP or a partner's own product, the OAuth client credentials flow is the better shape. The customer gets a client registration with an id and a certificate (a secret if they insist, and they will, and I say no more often than I used to), plus the scopes you assign. Their service posts to your identity provider's token endpoint, receives a JWT that lives an hour, and sends it as a bearer token. Your API validates it with the stock JwtBearer handler against the provider's signing keys, and maps the client id (the azp or appid claim) to a tenant through your registration table.

Everything I have to build by hand on path A comes with path B. Rotation is a certificate expiry. Short-lived tokens limit the blast radius of a leak. The audit trail is at the identity provider. The customer's engineer already has a library for it. On Entra the same flow is called application permissions, granted by an admin as app roles, and the token carries a roles claim; if your identity provider is Entra External ID, machine-to-machine is a paid add-on, which surprised a customer of mine in 2025.

The cost is the engineer. The operations lead with the cron script did not have one, and the ERP integrator two years later had three. Both were right to want what they wanted.

Rotation without downtime

A key that cannot be rotated without an outage will never be rotated. Azure API Management got this right years ago with a primary and a secondary key per subscription, and I copy the idea: a tenant may hold two active keys at once. Rotation is issue the second, let the customer switch, revoke the first, and the script never sees a 401. Keys expire after a year by default, with a mail thirty days before, and the last-used column tells support which keys are abandoned before they expire.

For client credentials the same overlap applies to certificates: accept the old and the new for the overlap window, then drop the old. The identity provider does this for you, which is one more reason path B is cheaper to run once it exists.

Rate limits per key

The limit has to be per credential, not per IP and not per tenant. Two scripts in one tenant should not be able to starve each other, and one bad script should not take the tenant's dashboard down with it. In 2020 this was a middleware around a ConcurrentDictionary with a window per key, which I do not miss. Since .NET 7 the middleware is built in and the partition key can be anything you can read from the request:

builder.Services.AddRateLimiter(o =>
{
    o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    o.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(ctx =>
    {
        var key = ctx.User.FindFirst("key_id")?.Value
               ?? ctx.User.FindFirst("azp")?.Value
               ?? ctx.Connection.RemoteIpAddress?.ToString() ?? "anon";
        var premium = ctx.User.HasClaim("tier", "premium");
        return RateLimitPartition.GetTokenBucketLimiter(key, _ => new TokenBucketRateLimiterOptions
        {
            TokenLimit = premium ? 6000 : 600,
            TokensPerPeriod = premium ? 100 : 10,
            ReplenishmentPeriod = TimeSpan.FromSeconds(1),
            QueueLimit = 0
        });
    });
});

UseRateLimiter must come after UseAuthentication or the claims are not there yet, which is the first thing to check when every caller lands in the anon bucket. The counters are in memory and per instance; with three instances a customer effectively gets three times the number on the contract. I have accepted that on every product so far and put the exact number in the gateway when a customer asked for it in writing.

The two paths I draw today

Keys for scripts, client credentials for platforms, and both producing the same principal so that nothing downstream knows which door was used. The trade-off is two authentication handlers to maintain instead of one, and I take it, because the alternative is a customer who cannot integrate at all. If I could only keep one thing from this sheet it would be the hash: a key table that stores the secret in the clear is a breach notification with a date you have not learned yet.

Drawn from