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 saysreports: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 heldadmin:global:manage→ stillForbidden. 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.