API keys and service accounts
Restricted credentials are opaque pdk_ keys stored as SHA-256 hashes; a user-bound key is its owner's live rights narrowed to the key, a service key is a service principal bounded by its creator, and tenant settings, revocation and events apply on every use.
An integration token for a CI job, a personal access token for a script, a key a customer pastes into Zapier: each is a secret that acts without a browser session. PermDock models all of them as a credential, a stored record the key stands for. The resolver turns a verified key into an ordinary subject, so can(), where(), snapshots and audit treat the caller like any other principal, only narrower.
There are two kinds:
| Kind | Acts as | Rights | Typical use |
|---|---|---|---|
user | Its owner, a user principal | The owner's live rights intersected with the key's permissions, inside one tenant when the key names one | Personal access tokens, scripts, a customer's own integrations |
service | Its own service principal | The roles it holds in one tenant, narrowed to the key's permissions, never more than its creator could hand out | CI, background jobs, a server-to-server integration that outlives any one employee |
Keys and storage
A key is opaque: pdk_<id>_<secret><checksum>. <id> is the credential id (1 to 128 of A-Z, a-z, 0-9, _ and -), <secret> 43 base62 characters, about 256 bits, and <checksum> 6 base62 characters: the CRC-32 (IEEE) of everything before it, most significant digit first over the alphabet A-Z a-z 0-9. The id lets the verifier find the one row it needs without scanning. The fixed pdk_ prefix and the checksum let a secret scanner recognise a leaked key and discard a lookalike offline, and parseApiKey rejects a key whose checksum does not match before any lookup. A scanner matches:
\bpdk_[A-Za-z0-9_-]{1,128}_[A-Za-z0-9]{49}\bSecret scanning
To have GitHub report pdk_ keys pushed to public repositories, join the GitHub secret scanning partner program with the pattern above and an HTTPS endpoint that receives matches. That endpoint verifies GitHub's signature, looks each key up with parseApiKey and your find, and revokes the ones that verify. The registration belongs to whoever issues the keys, so each application registers its own endpoint; PermDock ships the format, not the endpoint.
permdock/server ships the key helpers:
| Function | Does |
|---|---|
generateApiKey(id) | A fresh key for a credential id, from crypto.getRandomValues |
hashApiKey(key) | base64url SHA-256 of the whole key: the only value you store |
parseApiKey(key) | { id, secret }, or undefined for anything that is not a key |
apiKeyVerifier({ find }) | A CredentialVerifier over your table: find(id) returns { credential, hash }, and the hash is compared in constant time |
apiKeyVerifier({ find, touch }) | With touch(id, at): records each successful use (Unix seconds) for a lastUsedAt column |
memoryCredentials() | An in-process store for tests and single-replica apps, with issue, rotate, revoke, list and lastUsedAt(id) |
Show the key once, when it is created, and keep only the hash. A key is high-entropy, so a plain SHA-256 is enough; there is no password to stretch.
import { generateApiKey, hashApiKey } from "permdock/server";
const key = generateApiKey(credential.id);
await db
.insert(apiKeys)
.values({ id: credential.id, hash: await hashApiKey(key), credential });
return key; // shown onceCreating a key
decideCredential(permdock, request, { settings }) from permdock decides whether the current subject may create a key. It returns a decision with the usual three outcomes and, when granted, the Credential to store. PermDock never writes it; the application does.
import { decideCredential } from "permdock";
const decision = await decideCredential(
permdock,
{
kind: "service",
id: "svc_01J8",
principal: "ci-deploy",
tenant: "o_1",
roles: ["developer"],
permissions: [
permissions.repo.read,
{ permission: permissions.repo.write, ids: ["r_1", "r_2"] },
],
expiresAt: Math.floor(Date.now() / 1000) + 30 * 86_400,
name: "deploy pipeline",
},
{ settings },
);| Field | Meaning |
|---|---|
kind | user or service |
id | The credential id, the <id> of the key |
permissions | At least one permission reference, with no upper bound. { permission, ids } limits one to those resource ids. Required for both kinds: every key is restricted. |
principal | Service only: the service principal id (defaults to id) |
tenant | Service: the one tenant the service principal holds its roles in. User, optional: the one tenant of the creator's the key is held to |
roles | Service only: the roles the service principal holds in tenant |
expiresAt | Seconds since the epoch. Omit it only where the tenant allows keys that never expire. |
name | A label for the dashboard |
The checks, in order:
- The creator is signed in (
anonymousotherwise) and is not a link or a credential itself: a key cannot mint keys (exceeds-creator). - The request is well formed: known permissions, at least one entry, ids, an expiry in the future (
validation). - A delegated creator, such as an OAuth client with scopes, hands out only what its delegation covers (
exceeds-creator). - A key's tenant is one the creator is a member of. For a service key, each role is among the creator's
assignableRoles({ tenant }), and each permission is within the creator'sassignablePermissions({ tenant }), the custom-role ceiling (custom roles). Anything else isexceeds-creator, with the tenant, role or permission indetail. - The tenant's credential settings apply (
credential-policy, below). approval: truein those settings makes the outcomeapproval-required.
A user-bound key needs no ceiling check: its owner is its creator, and every request intersects it with the owner's rights anyway. Guard the endpoint that creates keys with its own permission, the same way a share link is guarded.
Tenant credential settings
A tenant can tighten what keys may be through a SettingsSource, the per-tenant settings the application stores:
import { memorySettings } from "permdock";
const settings = memorySettings({
o_1: {
credentials: { maxTtl: 90 * 86_400, kinds: ["service"], approval: true },
},
});| Rule | Refuses |
|---|---|
maxTtl | A key whose expiresAt is more than this many seconds after createdAt. A value that is not a finite non-negative number refuses every key. |
kinds | A kind that is not listed |
allowNoExpiry | Without it, every key must have expiresAt, and a key without one is refused even when no settings exist. maxTtl also refuses it. |
approval | Nothing, but creation is approval-required |
The settings that apply are the key's own tenant, and for a user-bound key without one the creator's active tenant at creation and the tenant the resolver is called with on use. A settingsFor that throws denies with rule: 'unavailable'. credentialPolicyViolation(credential, policy) returns the broken rule (kind, no-expiry or ttl) for a UI that explains it, and permdock doctor (PD029) flags fixture keys without expiry and tenants that allow them (doctor).
Approval
With approval: true, decideCredential returns { outcome: 'approval-required', credential, token }. The token is bound to the credential's content and its creator, so an approval for a read-only key cannot be reused to create a wider one. Route it through your approval flow, and after a human approves and your ApprovalStore consumes it, call decideCredential again with approved: token. It re-runs every check and grants only when the recomputed token matches (approvals).
Using a key
subjectFromApiKey(options) from permdock/server returns a SubjectResolver: call it with the key and, optionally, { tenant }. Like every subjectFrom* resolver it never throws; any failure is the anonymous subject, reported through onAuth.
import { apiKeyVerifier, subjectFromApiKey } from "permdock/server";
const resolveKey = subjectFromApiKey({
verifier: apiKeyVerifier({
find: (id) => db.query.apiKeys.findFirst({ where: eq(apiKeys.id, id) }),
}),
permissions,
settings,
revoked: async (id) =>
(await db.query.apiKeys.findFirst({ where: eq(apiKeys.id, id) }))
?.revokedAt != null,
owner: (id) => loadUser(id), // roles and memberships as of this request; null for a deleted user
sink, // credential used events
sample: 0.1,
});
const key = request.headers.get("authorization")?.replace(/^Bearer /u, "");
const subject = await resolveKey(key, { tenant: params.org });
const permdock = await createPermDock(
policy,
subject,
subject.principal?.kind === "service"
? {}
: { memberships, tenant: params.org },
);A user-bound key resolves to its owner, with the key in principal.credential and its permissions as a delegation:
{
"principal": {
"id": "u_1",
"kind": "user",
"memberships": [{ "tenant": "o_1", "roles": ["developer"] }],
"credential": {
"v": 1,
"id": "key_1",
"kind": "user",
"principal": "u_1",
"permissions": [
{ "permission": "repo.read" },
{ "permission": "repo.write", "ids": ["r_1"] }
],
"createdBy": "u_1",
"createdAt": 1790000000,
"expiresAt": 1792592000
}
},
"delegation": {
"scopes": ["repo:read"],
"authorizationDetails": [
{ "type": "repo", "actions": ["write"], "identifier": "r_1" }
]
},
"context": {},
"expiresAt": 1792592000
}Permissions on every instance become OAuth scopes and permissions limited to ids become RFC 9396 entries with an identifier, so the same coveredByDelegation check that narrows an OAuth client narrows the key: a check outside the key is not-delegated. An allow with requires counts for a key only when the key covers every required permission. The key must cover the granted permission too, unless that permission is read-only (meta.readOnly, or the read and list actions) and the allow is not a role grant: a key scoped to file:read reads the drives a requires: file.read share reaches, but not drives a role grant of drive.read alone reaches, and a key scoped to drive:read alone reads the latter but not the former. The same file:read key cannot update through an editor share whose node.update grant requires file.read, because a write never stands in for its required permission. This holds in process and under RLS alike. An entry whose identifier is not a string or whose actions is not an array covers nothing. The owner's roles and memberships are read live, from owner or from the memberships source passed to createPermDock, so a demoted owner's keys lose the same rights on the next request. A key whose permissions all disappeared from the definitions delegates nothing, and every check is no-delegation.
A user key that names a tenant is held to it. A personal key limited to one organization is the usual case: its owner belongs to several, and the key should reach one. createPermDock makes the key's tenant the active tenant, keeps only the owner's memberships inside it (those of the first scope's instance and every instance nested in it), and drops the owner's global roles, since a global role reaches every tenant. An instance created for another tenant, such as createPermDock({ tenant }) on a route of tenant T for a key held to H, answers for no tenant: it keeps no membership, reads no entitlements and denies every check, including a collection-level protect() without a row. Switching with permdock.tenant(id) from an instance of the key's tenant finds no membership and denies the same way, and tenants() lists only the key's. This holds whether the memberships come from owner, from the token or from a memberships source. ids stays what it is for every key, resource ids; never put a tenant id there. A verifier that loads keys for RLS, such as better-supabase's, copies the credential's tenant into the api_key claim's tenant, and the generated helpers hold the database to the same tenant (API keys in RLS).
A service key resolves to a service principal whose only membership is { tenant, roles, via: 'credential' }, with the same delegation. Its membership comes from the credential, so do not pass a memberships source to createPermDock for it: a source answers for the service principal too, and would replace that membership with whatever it finds (usually nothing, which denies).
The resolver refuses, each as the anonymous subject with an on('auth') event of source: 'api-key':
| Cause | When |
|---|---|
malformed | Not a pdk_<id>_<secret><checksum> key, or a checksum that does not match |
unknown-credential | The verifier found no matching key: unknown id, wrong secret, or revoked by the store |
invalid-claims | The record is not a valid v1 credential, or its id is not the key's |
expired | expiresAt is in the past |
credential-policy | The tenant's settings refuse the key, including a rule tightened after it was created |
credential-revoked | revoked(id) returned true |
owner-unavailable | The owner loader returned nothing, or someone other than the credential's user |
A verifier, settingsFor, revoked or owner that throws denies with reason source-threw.
Supabase secret keys
A Supabase project's own secret keys (sb_secret_…) are verified by @supabase/server, not by subjectFromApiKey. createPermDock({ secretKeys }) in permdock/supabase/middleware gives a named one the subject a service credential resolves to: { id, kind: 'service', tenant }, one { tenant, roles, via: 'credential' } membership and delegation scopes for its permissions. The map is code, so creating, revoking or narrowing such a key is a deploy, and the tenant settings above do not apply (Supabase).
Revocation and rotation
Revoke a key by marking its row, which find then skips, or through revoked(id), the same id-keyed revocation check link capabilities use. Rotating keeps the id and replaces the hash: generateApiKey(id) again, store the new hash, and the old key stops verifying at once. memoryCredentials() does both, reporting created, rotated and revoked events to its sink.
Events
Credential changes are credential sink events, CloudEvents type dev.permdock.credential:
{
"type": "credential",
"at": "2026-09-29T10:15:00Z",
"source": "api",
"operation": "used",
"credential": { "id": "svc_01J8", "kind": "service" },
"principal": { "id": "ci-deploy" },
"tenant": "o_1",
"expiresAt": 1792592000,
"sample": 0.1
}operation is created, rotated or revoked (build them with credentialEvent or let memoryCredentials emit them) or used, which subjectFromApiKey emits for a sampled fraction of resolutions. A used event always carries sample, the fraction reported, so a count multiplies back up; sample defaults to 1. Every decision a key makes also names it in subject.credential ({ id, kind }) on the decision event (audit and observability).
IP ranges and other request conditions
A key limited to an office network or a CI provider's egress range is a grant check on context.ip. The application puts the address it trusts (the socket peer, or the first hop its own proxy appends) into the subject's context, and a closure grant reads it:
const subject = await resolveKey(key, { tenant });
const permdock = createPermDock(policy, {
...subject,
context: { ...subject.context, ip: clientIp(request) },
});
allow(permissions.repo.write, (_row, { context }) =>
inRange(context.ip, ["203.0.113.0/24"]),
);The request's address is not in any token the database sees, so the check is not portable: it holds for can() and the API only, never in RLS, and where() marks the result partial. The key itself never reaches the database: RLS covers signed-in sessions and exchanged link tokens. A backend that verified a key and queries Postgres for it can pass the key's permissions and tenant in a claim, and with rls.apiKeys the generated helpers cap every allow at those permissions, hold a key that names a tenant to that tenant, and treat a tenant key without a subject as a service principal of that tenant (API keys in RLS).
Checking a verifier
testCredentialVerifier(verifier, { key, revoke? }) from permdock/testing checks a verifier against a live key: it verifies to a v1 credential with the key's id, every other string (a flipped secret, another id, a different prefix) is null without a throw, a touch, when the verifier has one, leaves what the key verifies to unchanged, and a revoked key stops verifying. testSettingsSource(source, { tenant }) checks a settings source (extension interfaces).
Why
API keys are where least privilege usually ends: a personal token that carries everything its owner can do, forever, even after the owner leaves the team. Binding a user key to its owner's live rights rather than a copy of them means a demotion, a suspension or a deletion reaches every key at once, and expressing the key's scope as a delegation reuses the check OAuth clients already go through instead of adding a second narrowing path. RFC 9396 entries with an identifier already express "these two repositories", so the key needs no new field for resource ids.
Service keys are principals of their own because a CI job should keep working when the engineer who set it up leaves, and should never be able to do more than that engineer could hand out. The creator's assignablePermissions is exactly that bound, the same ceiling custom roles use, so a service key cannot be a way around it.
Keys are opaque rather than signed. A signed key would verify without a lookup, which is the point of a capability link but the opposite of what a key needs: revocation, rotation, a tightened tenant rule and the owner's current rights all have to apply on every request, so the row is read anyway. The embedded id makes that one indexed lookup, and hashing means a database leak does not leak working keys. C6's capability format keeps holder: 'key' reserved for a future stateless key; this page adds no token format.
The checksum is CRC-32 rather than a keyed MAC because it is for scanners, which have no key: it only separates a real key from a random string that happens to match the pattern, and the secret's 256 bits still do all the security work. touch is optional and not awaited, because a lastUsedAt write is bookkeeping: it must not add a round trip to every request or turn a database hiccup into a denied key.
Tenant settings are read on use as well as at creation because a tenant that shortens its maximum lifetime means the keys already out there too. Keys without expiry are refused unless a tenant opts in, and doctor flags each opt-in, because a key that never expires is the one that leaks.
Last updated on
Link capabilities
A share link is a signed capability that holds roles on one resource, optionally narrowed to a few permissions; it resolves to a link principal, expires, can be revoked or used once, and reaches RLS through a short-lived Supabase token.
Authentication and PermDock
PermDock never authenticates: it consumes material something else has already verified, turns it into a subject, and decides. This page defines what counts as verified, which claims may feed grants, and how tokens map to principal, actor and delegation.