Auth & Security

API Keys

Mint, list, and revoke DB-backed tenant API keys through the v2 API — roles, scopes, environment scope, expiry, storage, and rotation.

DB-backed API keys are Ductor's durable, revocable, per-tenant credentials for service-to-service traffic. This page owns the security model: how a presented key is verified, what is stored, how roles and scopes are evaluated, and what threat boundaries the auth path enforces. Unlike JWTs (validated against your IdP) these are owned by Ductor and validated against the tenant_api_key table.

Choose the right API-key page

Auth · API keys owns the verification model, storage/hash/prefix scheme, roles and scopes, and threat model. Management · API keys owns mint, list, rotate, revoke, and the operational lifecycle API. Issue & use API keys owns the end-to-end task sequence and validation path.

Enable the auth path first:

export DUCTOR_AUTH_API_KEY_ENABLED=true

The secret is shown exactly once

CreateApiKey returns the plaintext key once, in the create response. It is never stored, logged, or retrievable afterward — only the key's prefix and a bcrypt hash are persisted. If you lose it, revoke and mint a new one.

The lifecycle API

Three REST endpoints (each also available over Connect-RPC). The tenant is always taken from the authenticated request context, never from the request body — you cannot mint or list keys for another tenant.

OperationMethod & pathRequired authorization
List keysGET /api/v2/api-keystenant:read
Create keyPOST /api/v2/api-keystenant:write and the admin role
Revoke keyPOST /api/v2/api-keys/{id}/revoketenant:write and the admin role

Mint a key

curl -sS -X POST https://api.example.com/api/v2/api-keys \
  -H "Authorization: Bearer $ADMIN_JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "CI Pipeline Key",
    "roles": ["operator"],
    "scopes": ["workflow:read", "workflow_run:read"],
    "environment_mode": "non_production_only",
    "expires_at": "2026-12-31T00:00:00Z"
  }'

Response — note secret, returned here and never again:

{
  "key": {
    "id": "b1e7…-uuid",
    "name": "CI Pipeline Key",
    "key_prefix": "duk_b1e7",
    "roles": ["operator"],
    "scopes": ["workflow:read", "workflow_run:read"],
    "status": "active",
    "environment_mode": "non_production_only",
    "environment_keys": [],
    "created_at": "2026-07-11T12:00:00Z"
  },
  "secret": "duk_b1e7_live_9f2c8a7b6d5e4f3a2b1c0d9e8f7a6b5c"
}

Use the returned secret on subsequent calls:

curl -sS https://api.example.com/api/v2/workflow-runs \
  -H "X-API-Key: duk_b1e7_live_9f2c8a7b6d5e4f3a2b1c0d9e8f7a6b5c"

List keys

Returns every key for the tenant — active and revoked, newest first — so a management UI can show full history. Records never include the secret or its hash.

curl -sS https://api.example.com/api/v2/api-keys \
  -H "Authorization: Bearer $ADMIN_JWT"
{
  "keys": [
    {
      "id": "b1e7…-uuid",
      "name": "CI Pipeline Key",
      "key_prefix": "duk_b1e7",
      "roles": ["operator"],
      "status": "active",
      "created_at": "2026-07-11T12:00:00Z",
      "last_used_at": "2026-07-11T12:03:41Z"
    }
  ],
  "total_count": 1
}

Revoke a key

curl -sS -X POST https://api.example.com/api/v2/api-keys/b1e7…-uuid/revoke \
  -H "Authorization: Bearer $ADMIN_JWT"

Revocation takes effect immediately — the auth path only ever considers active keys. Revoking an already-revoked key is a no-op. The action cannot be undone.

What a key carries

Roles

The optional roles list is validated against the RBAC role catalog: the built-in viewer, operator, admin, and service, plus any module-registered roles. An unknown role name is a 400. At auth time the role bundle expands to its scopes. See Authorization for what each role grants.

Scopes

The optional scopes list attaches per-key resource:action scopes. Each must be a known scope in the catalog (an unknown scope is rejected at mint time). Per-key scopes are unioned with the role-bundle expansion when the authorizer evaluates a request — so you can grant a key exactly workflow:read without handing it a whole role.

Environment scope

environment_mode constrains where a key may mutate environment-owned artifacts:

ModeMeaning
all (default)No environment restriction
selectedOnly the environments listed in environment_keys
production_onlyOnly production environments
non_production_onlyEverything except production

environment_keys is only valid with selected (and must be non-empty there); supplying keys without selected — or with any other mode — is rejected. If you pass environment_keys but omit environment_mode, the server treats the request as selected. Read-only calls are still governed by RBAC scopes regardless of environment mode.

Expiry

expires_at is an optional RFC-3339 timestamp. Unset means the key never expires. The authentication path rejects a key presented after its expiry.

Storage & validation model

  • Never plaintext. Only two things are persisted: the key prefix (first 8 characters, e.g. duk_b1e7, shown for identification) and a bcrypt hash of the full key. The scheme the mint side writes is exactly what the auth side validates against.
  • Prefix-narrowed lookup. On a presented key, the store fetches active candidates by prefix — at most one or two rows — and bcrypt-compares against those. This keeps validation cheap and bounds the work an attacker can force.
  • Role storage. The per-key role bundle was added in migration 142; an empty role list simply disables the role gate while per-key scopes still apply.

Rotation

Keys have no in-place rotation — rotation is mint-then-revoke, which lets you cut over with zero downtime:

Mint a new key with the same roles/scopes/environment scope.

Deploy it to the consumer and confirm traffic is flowing on the new key_prefix (watch last_used_at).

Revoke the old key. It stops authenticating immediately.

Set an expiry as a safety net

Set expires_at on machine keys so a forgotten key eventually self-retires even if step 3 is missed.

Auditing key lifecycle

Because API keys are bearer credentials, their issuance, status changes, and revocation are recorded to the same audit stream the authorizer uses (tenant.apikey.created, tenant.apikey.status_changed, tenant.apikey.revoked). The audit event carries the actor, tenant, and key ID — never the plaintext or the key name. Configure the audit sink as described in Authorization → Audit logging.