Articles · Machine Identity

API keys, owner-capped

Try out the running example

The personal API key is the most abused credential shape in the industry: minted once with the user’s full authority, pasted into a script, outliving the user’s role, their team, sometimes their employment. Sentinel keeps the convenient shape and removes the lie at its center — a key is never a copy of the owner’s authority, it’s a view of it, recomputed every time the key is used.

Minting

var created = await machineAuth.CreateApiKeyAsync(
    principal.RealmId, principal.SubjectId, request.Scopes,
    principal.OrganizationId, expiresAt);

// The full token appears in this response and NOWHERE else — only its hash is stored.
return Results.Ok(new { created.Key.Id, created.Key.Prefix, created.Key.Scopes, token = created.Token });

The token is snt_ + 43 base64url characters — 256 random bits. What lands in the store is its SHA-256 and a 12-character display prefix (snt_a1B2c3D4): recognizable in a key list and in a leaked-credential scanner, useless to an attacker who reads your database. Scope patterns are validated against the permission grammar at mint time — a malformed scope is a FormatException now, not a silent never-matches later.

Note what the library doesn’t ship: the management HTTP surface. MachineAuthService and the authentication plumbing are Sentinel’s; POST /keys, GET /keys, POST /keys/{id}/revoke are ~60 lines of host code in the sample, shaped however your product needs. One rule worth copying: only a User principal may mint — keys don’t beget keys.

The cap: owner ∩ scopes, at every use

When a request arrives with a key, Sentinel loads the owner’s current grants and intersects each allow with the key’s scope patterns:

  • Owner allows reports:org:*, scope says reports:org:* → the key reads reports.
  • Owner also allows billing:org:* — but no scope covers it → capped away. Scopes narrow.
  • Owner has an explicit deny on reports:org:purge → carried into the key’s snapshot unconditionally, even though the scope pattern covers purge. A key can never escape its owner’s denies.
  • Scope says *:*:*, owner never held admin:global:manage → still Forbidden. Scopes never add authority — the intersection of everything with nothing is nothing (Scopes_never_add_authority_beyond_the_owner).

The pattern intersection is the same segment-wise operation the break-glass cap uses: literal beats wildcard per segment, disjoint literals kill the pattern. And because the snapshot is rebuilt from the live grant source on each authenticated request, the cap tracks reality:

// Demote Maya to read-only. Nothing on the key row changes.
grants.SetGrants(MayaId, [GrantDirectory.Allow("reports:org:read")]);

Every key she owns loses reports:org:export on its next request — no key rotation, no cleanup job, no forgotten-credential audit. Fire the owner and the keys die with their authority (Demoting_the_owner_shrinks_every_existing_key_on_its_next_use).

Who is the principal?

A key-authenticated request is not the owner in disguise:

GET /whoami   →   { "kind": "ApiKey", "subjectId": "<key id>", "ownerUserId": "<maya>" }

SubjectId is the credential — so two keys owned by the same person are distinguishable in every audit line — and OwnerUserId names the human whose authority is being exercised. Rate-limit per key, revoke per key, attribute per key; blame per owner. The same split shows up in /profile/permissions, which for a key discloses the capped set — the introspection surface never claims more than the evaluator would allow.

One handler, three credential shapes

services.AddSingleton<IMachineIdentityStore, InMemoryMachineIdentityStore>();  // opt-in
services.AddSentinelAuthentication(o => { /* issuer, audience, realm */ });

There is no second authentication scheme to configure. Sentinel’s handler inspects the credential: a JWT goes down the token path, anything starting with snt_ goes down the key path (Bearer snt_… or a bare header value), cookies handle the browser. Your endpoints authorize against the snapshot exactly as they would for a logged-in user — the sample’s /reports guard is four lines of AuthorizationEvaluator.Evaluate and doesn’t know which credential shape arrived.

Failure is deliberately monotone: unknown token, revoked key, expired key — all the same opaque 401 (Revoked_and_expired_keys_fail_with_the_same_opaque_401). An error body that distinguishes “revoked” from “never existed” is an oracle for credential scanning. Internally, each successful use stamps LastUsedAt (throttled, best-effort) so a stale-key report is a query, not a guess.

Keys or service accounts?

Owner-capped keys answer “let my script do what I can, within this slice” — personal automation, CLI tools, one-off integrations. The moment the credential should survive its creator — CI deployers, cron services, anything a team owns — you want a service account with its own grants, or better, workload federation with no stored secret at all. The rule of thumb the sample encodes: if the key outliving the owner’s demotion would be a bug, it’s an API key; if it would be an outage, it’s a service account.