# API keys and service accounts

Source: https://permdock.com/docs/concepts/credentials

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 [#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:

```text
\bpdk_[A-Za-z0-9_-]{1,128}_[A-Za-z0-9]{49}\b
```

### Secret scanning [#secret-scanning]

To have GitHub report `pdk_` keys pushed to public repositories, join the [GitHub secret scanning partner program](https://docs.github.com/en/code-security/secret-scanning/secret-scanning-partnership-program/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.

```ts
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 once
```

## Creating a key [#creating-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.

```ts
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:

1. The creator is signed in (`anonymous` otherwise) and is not a link or a credential itself: a key cannot mint keys (`exceeds-creator`).
2. The request is well formed: known permissions, at least one entry, ids, an expiry in the future (`validation`).
3. A delegated creator, such as an OAuth client with scopes, hands out only what its delegation covers (`exceeds-creator`).
4. 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's `assignablePermissions({ tenant })`, the custom-role ceiling ([custom roles](/docs/concepts/custom-roles)). Anything else is `exceeds-creator`, with the tenant, role or permission in `detail`.
5. The tenant's credential settings apply (`credential-policy`, below).
6. `approval: true` in those settings makes the outcome `approval-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 [#tenant-credential-settings]

A tenant can tighten what keys may be through a `SettingsSource`, the per-tenant settings the application stores:

```ts
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](/docs/cli/doctor)).

### Approval [#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](/docs/adapters/approvals)).

## Using a key [#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`.

```ts
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:

```json
{
  "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`](/docs/concepts/policies#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](/docs/cli/rls#api-keys)).

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 [#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](/docs/adapters/supabase#secret-keys-as-service-principals)).

### Revocation and rotation [#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 [#events]

Credential changes are `credential` sink events, CloudEvents type `dev.permdock.credential`:

```json
{
  "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](/docs/concepts/audit-and-observability)).

## IP ranges and other request conditions [#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:

```ts
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](/docs/cli/rls#api-keys)).

## Checking a verifier [#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](/docs/concepts/extension-interfaces)).

## Why [#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.
