BetterIAM

API keys and service accounts

Service accounts and their API keys in Better IAM, covering issuance with credentials.create, scopes, expiry, rotation, revocation, unused-key hygiene, and how keys are presented.

Scripts, integrations, and other servers cannot type a password or answer an MFA prompt. They authenticate as a , an identity for software that lives in a 's directory next to people, with an API key: an opaque bearer token sent as Authorization: Bearer <key>. Only the key's SHA-256 hash is stored, so a database leak does not reveal usable keys.

A key acts as its service account, with every role, group membership, and relationship the account holds, and can be narrowed further with scopes. Every key expires, records when it was last used, and can be rotated or revoked on its own. Use API keys for long-running integrations that cannot obtain a token any other way. For CI jobs and workloads whose platform issues OpenID Connect tokens, prefer web-identity federation, which needs no stored secret at all.

Setup

Declare the actions the key will use

Keys are checked against your permission catalog like any other credential, and scopes must name actions the catalog knows (INVALID_ACTION otherwise). Checks on your own resource types also need a resolveResource that says which tenant owns a record.

iam.ts
import { betterIam } from 'better-iam';
import { sqliteAdapter } from 'better-iam/adapter-sqlite';
import { findReport } from './reports';

export const iam = betterIam({
  database: sqliteAdapter({ filename: './iam.db' }),
  secret: process.env.BETTER_IAM_SECRET!,
  baseURL: process.env.BETTER_IAM_BASE_URL!,
  permissions: {
    resourceTypes: {
      report: { actions: ['reports:read', 'reports:export'] },
    },
  },
  resolveResource: async ({ type, id }) => {
    const report = await findReport(id); // your own database
    return { type, id, tenantId: report.tenantId };
  },
});

Migrate the database

API keys are stored as sessions of kind api-key; service accounts are identities of kind service. Both collections come with the standard schema.

npx better-iam migrate --config better-iam.config.mjs

Create a service account

serviceAccounts.create needs iam:identities:create on the tenant and an active . The new account is active but holds no access.

scripts/create-service-account.ts
import { iam } from './iam';

const exporter = await iam.api.serviceAccounts.create(adminCredential, {
  tenantId,
  name: 'Nightly export',
  description: 'Exports reports to the finance warehouse',
  expiresAt: Date.parse('2027-06-30T00:00:00Z'), // optional scheduled deactivation
});

Route: POST /serviceAccounts/create

Give it access

Bind a role to the account, or add it to a group, exactly as for a person.

scripts/grant-exporter.ts
import { iam } from './iam';

await iam.api.bindings.create(adminCredential, {
  tenantId,
  roleId: reportsReader.id,
  subjectType: 'identity',
  subjectId: exporter.id,
});

Usage

Every credentials method takes the caller's credential first and is authorized against iam:credentials:* actions on the service account (iam/{identityId}) or the key. Browser code calls the same methods on the typed client without the credential. Routes are relative to the handler's basePath (/api/iam by default).

Issue a key

credentials.create issues a key and returns its token once. Store it in your secret manager right away; it can never be read again.

scripts/issue-key.ts
import { iam } from './iam';

const { token, credentialId, expiresAt } = await iam.api.credentials.create(adminCredential, {
  tenantId,
  identityId: exporter.id,
  name: 'warehouse-export',
  description: 'Cron job on the analytics cluster',
  scopes: ['reports:read', 'reports:export'], // or a session `policy`; not both
  expiresInSeconds: 30 * 86_400, // 30 days; 90 days when omitted
});

Route: POST /credentials/create

  • Requires iam:credentials:create on the service account, recent authentication (RECENT_AUTH_REQUIRED), and an active grant authority (GRANT_AUTHORITY_REQUIRED). Because the permission is checked on the account, you can let a team issue keys for its own integration accounts only.
  • The identity must be an active, unexpired service account, or an AI agent in good standing (INVALID_IDENTITY). People never get API keys; they use sessions.
  • expiresInSeconds is 60 seconds to 365 days, 90 days by default.
  • name is at most 128 characters and description at most 512.
  • The key is tied to the issuer's grant authority (credentialAuthorityId) and stays within its ceiling. When that authority is revoked, for example because the issuing administrator was offboarded, the key stops working.
  • Audited as iam:credentials:create on the service account.

Limit a key with scopes or a policy

A key can do only what both its service account's grants and its own limit allow. Pass one of:

  • scopes: a list of actions. It compiles to a session policy that allows exactly those actions on every resource. An empty list fails with INVALID_INPUT.
  • policy: a full policy document, when the limit depends on resources or conditions.

Passing both fails with INVALID_INPUT. A key that itself has scopes or a policy can only issue keys within them (ACCESS_DENIED otherwise), and a key it issues without its own limit inherits the caller's.

scripts/issue-scoped-key.ts
import { iam } from './iam';

await iam.api.credentials.create(adminCredential, {
  tenantId,
  identityId: exporter.id,
  name: 'read-one-project',
  policy: {
    version: 1,
    statements: [{ effect: 'allow', actions: ['reports:read'], resources: ['report/project-42-*'] }],
  },
});

Use a key

Present the key as a bearer token. Over HTTP it works for every route, never touches cookies, and needs the JSON content type and X-Better-IAM: 1 like every other call.

app/api/reports/export.ts
import { iam } from './iam';

// Your own route, called by the integration with Authorization: Bearer <key>.
export async function POST(request: Request) {
  const decision = await iam.authorize({
    headers: request.headers,
    tenantId,
    action: 'reports:export',
    resource: { type: 'report', id: 'quarterly' },
  });
  if (!decision.allowed) return new Response('Forbidden', { status: 403 });
  return Response.json(await exportReport('quarterly'));
}
  • Policies see the caller as principal.kind: 'service' and principal.sessionKind: 'api-key', so a statement can treat machines differently from people. API key sessions are never MFA-verified (principal.mfa is false).
  • A key is judged against the address presenting it: a key used from a network blocked with security.blockNetwork is refused. The tenant's allowedIpRanges allowlist applies to user sessions, session tokens, and role sessions, not to API keys.
  • Operations that need recent authentication accept a key only within recentAuthenticationMs (five minutes by default) of its creation or rotation, because a key's authentication time is its creation time.
  • A key is refused as soon as its account is disabled, expires, or is deleted, or its grant authority is revoked.

List keys and find unused ones

credentials.list lists the keys of the tenant, or of one service account with identityId, newest first, without token material. unusedForMs keeps only keys that have not authenticated a request in that long, including keys never used since they were issued.

scripts/revoke-unused-keys.ts
import { iam } from './iam';

const unused = await iam.api.credentials.list(adminCredential, {
  tenantId,
  unusedForMs: 60 * 86_400_000, // 60 days
});
for (const key of unused) {
  await iam.api.credentials.revoke(adminCredential, { tenantId, credentialId: key.id });
}

Route: POST /credentials/list

  • lastUsedAt is recorded at most once a minute and is absent until first use.
  • Expired keys stay listed with expired: true until you renew or revoke them.
  • unusedForMs must be between 0 and ten years (INVALID_INPUT).
  • analysis.findings reports unused keys as stale-api-key, and reports.access lists keys unused or ending soon.

credentials.get returns one key the same way. Route: POST /credentials/get

Relabel a key or move its expiry

credentials.update changes name, description, or expiresAt without changing the token. Pass null to clear a label. A new expiresAt must be in the future and at most a year away, and can renew an expired key, which then works again without a new token.

scripts/extend-key.ts
import { iam } from './iam';

await iam.api.credentials.update(adminCredential, {
  tenantId,
  credentialId,
  description: null,
  expiresAt: Date.now() + 90 * 86_400_000,
});

Route: POST /credentials/update

Changing expiresAt also needs recent authentication and the key's grant authority: only the issuer, or the platform root, may move another administrator's key (ACCESS_DENIED).

Rotate a key

credentials.rotate replaces a key with a new token in one transaction. The old token stops working the moment the new one exists, so deploy the returned token before anything else.

scripts/rotate-key.ts
import { iam } from './iam';

const { token, credentialId: newId } = await iam.api.credentials.rotate(adminCredential, {
  tenantId,
  credentialId,
});
await secrets.put('warehouse-export', token);

Route: POST /credentials/rotate

  • The replacement gets a new credentialId and keeps the name, description, scopes or policy, and expiry. Its usage history starts over, so it shows as unused until the integration uses it.
  • Requires iam:credentials:create on the key and recent authentication. Only the issuer (the holder of the key's grant authority) or the platform root may rotate it (ACCESS_DENIED).
  • Rotate on a schedule, or at once when a token may have leaked.

Revoke a key

credentials.revoke deletes a key so it stops working immediately. It cannot be undone.

scripts/revoke-key.ts
import { iam } from './iam';

await iam.api.credentials.revoke(adminCredential, { tenantId, credentialId });

Route: POST /credentials/revoke

To stop every key of an account at once, disable the account with serviceAccounts.setStatus, or delete it with serviceAccounts.delete. identities.revokeSessions ends every session of an identity and, unless keepApiKeys: true is passed, its API keys too.

Derive short-lived tokens from a key

A key is long-lived. For each CI job or batch run, trade it for a session token that lasts minutes to hours and ends with the key: sts.getSessionToken with the key as the credential. The token keeps the key's scopes and authority. See session tokens.

worker/job-token.ts
import { iam } from './iam';

const { token, expiresAt } = await iam.api.sts.getSessionToken(
  { token: process.env.IAM_API_KEY },
  { durationSeconds: 900, sessionName: 'export-run-118' },
);

Route: POST /sts/getSessionToken

Key format and secret scanning

New keys are 58 characters: biam_key_, 43 random base64url characters, and a 6-character CRC-32 checksum. Secret scanners can recognize a leaked key with credentialTokenScanPattern from better-iam/auth:

scripts/scan.ts
import { credentialTokenScanPattern, parseCredentialToken } from 'better-iam/auth';

const pattern = new RegExp(credentialTokenScanPattern, 'g');
const leaked = [...source.matchAll(pattern)].map((match) => match[0]);
parseCredentialToken(leaked[0] ?? ''); // { type: 'key' } when the shape and checksum match

The checksum filters typos and garbage; it authenticates nothing. Keys issued before the format existed keep working until they expire. See token formats.

Options

API keys have no deployment switch: they are always available. These inputs and settings shape them.

OptionTypeDefaultDescription
expiresInSecondsnumber7776000 (90 days)Key lifetime at issuance, 60 seconds to 365 days.
scopesstring[]noneAction allowlist compiled into a session policy over every resource.
policyPolicyDocumentnoneA full session policy instead of scopes.
namestringnoneLabel for reviews, at most 128 characters.
descriptionstringnonePurpose, at most 512 characters.
authentication.recentAuthenticationMsnumber300000 (5 minutes)How long after creation or rotation a key passes recent-authentication checks.
Service account expiresAtnumber (epoch ms)noneScheduled deactivation of the account; every key is refused from that instant.

Schema

CollectionKey fieldsPurpose
identitieskind: 'service', name, description, status, expiresAtThe service account.
sessionskind: 'api-key', tokenHash (SHA-256), identityId, name, description, policy, credentialAuthorityId, createdAt, expiresAt, lastSeenAt, mfa: falseOne row per key. lastSeenAt is reported as lastUsedAt once the key has been used.

credentials.get and credentials.list return a CredentialSummary: id, identityId, name, description, createdAt, expiresAt, lastUsedAt, expired, scopes (when issued with scopes), policy, and credentialAuthorityId. No summary ever carries the token or its hash.

Errors

CodeWhen
INVALID_IDENTITYThe identity is not an active, unexpired service account (or an agent in good standing).
INVALID_INPUTBoth scopes and policy, an empty scopes list, a lifetime outside 60 seconds to a year, or nothing to update.
INVALID_ACTIONA scope names an action the catalog does not know.
INVALID_POLICYThe session policy does not validate.
GRANT_AUTHORITY_REQUIREDThe caller holds no grant authority to issue keys under.
RECENT_AUTH_REQUIREDCreate, rotate, revoke, or an expiry change from a session that is not recent.
ACCESS_DENIEDMissing permission, rotating or re-dating another administrator's key, or a scoped key issuing a broader one.
INVALID_CREDENTIALThe credentialId belongs to a session that is not an API key.
NOT_FOUNDThe key or account is not in this tenant.
IMPERSONATION_RESTRICTEDCreating, rotating, revoking, or re-dating a key from a "view as" session.

Next steps

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page