# API Keys (/docs/auth/api-keys)



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.

<Callout title="Choose the right API-key page" type="info">
  [Auth · API keys](/docs/auth/api-keys) owns the verification model,
  storage/hash/prefix scheme, roles and scopes, and threat model.
  [Management · API keys](/docs/management/api-keys) owns mint, list, rotate,
  revoke, and the operational lifecycle API.
  [Issue & use API keys](/docs/guides/api-keys) owns the end-to-end task sequence
  and validation path.
</Callout>

Enable the auth path first:

```bash
export DUCTOR_AUTH_API_KEY_ENABLED=true
```

<Callout title="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.
</Callout>

## The lifecycle API [#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.

| Operation  | Method & path                       | Required authorization                  |
| ---------- | ----------------------------------- | --------------------------------------- |
| List keys  | `GET /api/v2/api-keys`              | `tenant:read`                           |
| Create key | `POST /api/v2/api-keys`             | `tenant:write` **and** the `admin` role |
| Revoke key | `POST /api/v2/api-keys/{id}/revoke` | `tenant:write` **and** the `admin` role |

### Mint a key [#mint-a-key]

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

```json
{
  "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:

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

### List keys [#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.

```bash
curl -sS https://api.example.com/api/v2/api-keys \
  -H "Authorization: Bearer $ADMIN_JWT"
```

```json
{
  "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 [#revoke-a-key]

```bash
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 [#what-a-key-carries]

### Roles [#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](/docs/auth/authorization) for what each role grants.

### Scopes [#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-scope]

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

| Mode                  | Meaning                                            |
| --------------------- | -------------------------------------------------- |
| `all` (default)       | No environment restriction                         |
| `selected`            | Only the environments listed in `environment_keys` |
| `production_only`     | Only production environments                       |
| `non_production_only` | Everything 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 [#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 [#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 [#rotation]

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

<Steps>
  <Step>
    **Mint** a new key with the same roles/scopes/environment scope.
  </Step>

  <Step>
    **Deploy** it to the consumer and confirm traffic is flowing on the new
    `key_prefix` (watch `last_used_at`).
  </Step>

  <Step>
    **Revoke** the old key. It stops authenticating immediately.
  </Step>
</Steps>

<Callout title="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.
</Callout>

## Auditing key lifecycle [#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](/docs/auth/authorization#audit-logging).
