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



API keys are the fallback credential that authenticates calls when you're not
presenting an OIDC JWT. Each key is **tenant-owned**, carries a role/scope
bundle, and can be constrained by expiry and environment. This page covers the
operational lifecycle API: mint, list, rotate, revoke, and inspect the metadata
operators need to run those credentials safely.

<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>

<Callout title="Where it lives">
  Keys are served by `TenantApiKeyService` under `/api/v2/api-keys` and stored in
  the `tenant_api_key` table. Secrets are **bcrypt-hashed at rest** — the
  plaintext key is returned exactly once, at creation, and is never stored,
  logged, or recoverable afterward. The application aggregate is at
  `application/tenantapikey/service.go`.
</Callout>

## The key record [#the-key-record]

`ListApiKeys` and the create response return an `ApiKeyRecord` — metadata only,
never the secret or its hash:

<TypeTable
  type="{
  id: { description: &#x22;Output-only key ID.&#x22;, type: &#x22;string&#x22; },
  name: { description: &#x22;Human label, unique per tenant.&#x22;, type: &#x22;string&#x22; },
  key_prefix: { description: &#x22;Output-only. Leading characters only (e.g. duk_abc1) — for identifying a key in a list, never the full secret.&#x22;, type: &#x22;string&#x22; },
  roles: { description: &#x22;RBAC role bundle.&#x22;, type: &#x22;repeated string&#x22; },
  scopes: { description: &#x22;Explicit resource:action scopes.&#x22;, type: &#x22;repeated string&#x22; },
  status: { description: &#x22;active or revoked.&#x22;, type: &#x22;string&#x22; },
  expires_at: { description: &#x22;Unset means never expires.&#x22;, type: &#x22;timestamp&#x22; },
  environment_mode: { description: &#x22;One of: all, selected, production_only, non_production_only.&#x22;, type: &#x22;string&#x22; },
  environment_keys: { description: &#x22;Which environments, when mode is selected.&#x22;, type: &#x22;repeated string&#x22; },
  created_at: { description: &#x22;Output-only; unset when N/A.&#x22;, type: &#x22;timestamp&#x22; },
  last_used_at: { description: &#x22;Output-only; unset when N/A.&#x22;, type: &#x22;timestamp&#x22; },
  revoked_at: { description: &#x22;Output-only; unset when N/A.&#x22;, type: &#x22;timestamp&#x22; },
}"
/>

### Roles and scopes [#roles-and-scopes]

* **`roles`** — validated against Ductor's RBAC catalog. Built-ins are `viewer`,
  `operator`, `admin`, and `service`, plus any roles a module registers. An
  unknown role is rejected.
* **`scopes`** — optional, fine-grained `resource:action` grants (e.g.
  `pool:read`, `rule:write`) that are unioned with whatever the roles expand to.

Scope a key to the *least* privilege its integration needs. A read-only
dashboard key gets `viewer`; a service that only creates runs gets a narrow
scope set, not `admin`. For the full verification and scope-expansion model, see
[Auth · API keys](/docs/auth/api-keys).

### Environment constraints [#environment-constraints]

`environment_mode` limits which environments a key may mutate:

| Mode                  | Effect                                              |
| --------------------- | --------------------------------------------------- |
| `all` (default)       | No environment restriction.                         |
| `selected`            | Only the environments listed in `environment_keys`. |
| `production_only`     | Only production environments.                       |
| `non_production_only` | Everything except production.                       |

Supplying `environment_keys` with an empty mode is treated as `selected`.

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

`CreateApiKey` — `POST /api/v2/api-keys` — requires `tenant:write` and the
`admin` role. The plaintext `secret` is in the response **once**.

```bash
curl -s -X POST https://api.ductor.io/api/v2/api-keys \
  -H "Authorization: Bearer $DUCTOR_ADMIN_TOKEN" \
  -H "X-Tenant-ID: $DUCTOR_TENANT" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "routing-service-prod",
    "roles": ["operator"],
    "scopes": ["pool:read", "recipient:read", "rule:read"],
    "environment_mode": "production_only",
    "expires_at": "2027-01-01T00:00:00Z"
  }'
```

```json
{
  "key": {
    "id": "b2c3d4e5-...",
    "name": "routing-service-prod",
    "key_prefix": "duk_9f2a",
    "roles": ["operator"],
    "scopes": ["pool:read", "recipient:read", "rule:read"],
    "status": "active",
    "environment_mode": "production_only",
    "expires_at": "2027-01-01T00:00:00Z",
    "created_at": "2026-07-11T10:35:00Z"
  },
  "secret": "duk_9f2a1b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a"
}
```

<Callout title="Capture the secret now — there is no second chance">
  The `secret` field is output-only and returned exactly once. If you lose it,
  you cannot recover it: revoke the key and mint a new one. Store it in a secrets
  manager the moment you receive it; never commit it or log it.
</Callout>

Present the secret on subsequent calls as the bearer token:

```bash
curl -s https://api.ductor.io/api/pools \
  -H "Authorization: Bearer duk_9f2a1b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a" \
  -H "X-Tenant-ID: $DUCTOR_TENANT"
```

## List keys [#list-keys]

`ListApiKeys` — `GET /api/v2/api-keys` (`tenant:read`) — returns the tenant's
keys, active and revoked, newest first. Records never include the secret or
hash.

```bash
curl -s https://api.ductor.io/api/v2/api-keys \
  -H "Authorization: Bearer $DUCTOR_ADMIN_TOKEN" \
  -H "X-Tenant-ID: $DUCTOR_TENANT"
```

```json
{
  "keys": [
    { "id": "b2c3d4e5-...", "name": "routing-service-prod", "key_prefix": "duk_9f2a",
      "status": "active", "roles": ["operator"], "last_used_at": "2026-07-11T10:40:00Z" }
  ],
  "total_count": 1
}
```

Use `key_prefix` and `last_used_at` to reconcile which key is which and spot
stale credentials.

## Revoke a key [#revoke-a-key]

`RevokeApiKey` — `POST /api/v2/api-keys/{id}/revoke` (`tenant:write`, `admin`) —
marks the key revoked; it stops authenticating **immediately**. Revoking an
already-revoked key is a no-op, and revocation cannot be undone.

```bash
curl -s -X POST https://api.ductor.io/api/v2/api-keys/b2c3d4e5-.../revoke \
  -H "Authorization: Bearer $DUCTOR_ADMIN_TOKEN" \
  -H "X-Tenant-ID: $DUCTOR_TENANT"
```

<Callout type="info">
  Because revocation is instant and irreversible, rotation is
  mint-then-revoke: create the replacement key, cut traffic over to it, confirm
  it's serving (watch `last_used_at`), then revoke the old one. Never revoke
  first — you'll create an outage window.
</Callout>

## Operational checklist [#operational-checklist]

* **Rotate on a schedule** using expiry (`expires_at`) so keys age out even if
  no one remembers to rotate them. Expired keys are rejected at the auth path.
* **One key per integration**, named for the integration, so revocation has a
  blast radius of one consumer.
* **Least privilege**: prefer explicit `scopes` over broad roles; reserve
  `admin` for genuine tenant/key administration.
* **Audit** every lifecycle change — creation, status change, and revocation are
  recorded in a hash-chained trail keyed on `tenant_api_key`.

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="Auth · API keys" href="/docs/auth/api-keys">
    Verification, storage, prefix lookup, RBAC roles, and scope expansion.
  </Card>

  <Card title="Issue & use API keys" href="/docs/guides/api-keys">
    The end-to-end task path for minting, using, validating, and revoking a key.
  </Card>

  <Card title="Tenants" href="/docs/management/tenants">
    The boundary every key is scoped to.
  </Card>

  <Card title="Managing resources" href="/docs/management">
    The conventions — headers, pagination, errors — shared by every call.
  </Card>
</Cards>
