# Issue & use API keys (/docs/guides/api-keys)



Ductor supports two authentication modes: **OIDC JWTs** (the primary path) and
**DB-backed API keys** for service-to-service calls. This page owns the
end-to-end task sequence: enable the auth path, mint a key, capture the secret,
authenticate a request, then list and revoke the key to validate the lifecycle.

<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 type="warn" title="Enable API-key auth first">
  DB-backed API keys are gated by `auth.api_key_enabled`. Set
  `DUCTOR_AUTH_API_KEY_ENABLED=true` (with `auth.enabled=true`) so keys are
  accepted. The tenant of a key always comes from the authenticated context that
  created it — never from the request body.
</Callout>

## 1. Create a key [#1-create-a-key]

`CreateApiKey` requires the `tenant:write` scope and the `admin` role. It maps to
`POST /api/v2/api-keys`:

```bash
curl -s -X POST http://localhost:8080/api/v2/api-keys \
  -H "Authorization: Bearer $ADMIN_JWT" \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "ci-deploy-key",
    "roles": ["operator"],
    "scopes": ["pool:write"],
    "environment_mode": "production_only"
  }'
```

A key carries four things worth knowing at mint time — RBAC **roles**, optional
per-key **scopes**, an optional **expiry**, and an **environment mode**:

| Field              | Meaning                                                                                                     |
| ------------------ | ----------------------------------------------------------------------------------------------------------- |
| `name`             | Required, unique per tenant.                                                                                |
| `roles[]`          | RBAC roles — validated against the catalog (`viewer` / `operator` / `admin` / `service` plus module roles). |
| `scopes[]`         | Optional per-key `resource:action` grants (e.g. `pool:write`).                                              |
| `expires_at`       | Optional RFC-3339 timestamp; unset means the key never expires.                                             |
| `environment_mode` | `all` (default), `selected`, `production_only`, or `non_production_only`.                                   |

The full field set — including `environment_keys[]` for `selected` mode — is
documented in [Management · API keys](/docs/management/api-keys).

## 2. Capture the secret — it's shown once [#2-capture-the-secret--its-shown-once]

The response carries the new key record **and** the plaintext `secret`. The
secret is never stored, logged, or retrievable again — only its bcrypt hash and
prefix persist:

```json
{
  "key": {
    "id": "01J...",
    "name": "ci-deploy-key",
    "key_prefix": "duk_9f2a",
    "roles": ["operator"],
    "status": "active",
    "created_at": "2026-07-11T12:00:00Z",
    "scopes": ["pool:write"],
    "environment_mode": "production_only"
  },
  "secret": "duk_9f2a1b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a"
}
```

A secret is the literal `duk_` prefix followed by \~43 URL-safe characters of
CSPRNG entropy; only its first 8 characters (`duk_9f2a`) are stored as
`key_prefix`, alongside a bcrypt hash of the whole thing.

<Callout type="error" title="Store the secret immediately">
  If you lose `secret`, you cannot recover it — revoke the key and mint a new
  one. The `key_prefix` (e.g. `duk_9f2a`) is safe to log and helps you identify a
  key later without exposing it.
</Callout>

## 3. Authenticate with the key [#3-authenticate-with-the-key]

Send the plaintext secret on requests. Ductor accepts it either as an API-key
header or, where supported, as a bearer credential:

```bash
curl -s http://localhost:8080/api/pools \
  -H "X-API-Key: $DUCTOR_API_KEY"
```

The tenant, roles, and scopes are resolved from the stored key record — the key
carries its own authorization context.

## 4. List and revoke [#4-list-and-revoke]

```bash
# list (requires tenant:read)
curl -s http://localhost:8080/api/v2/api-keys \
  -H "Authorization: Bearer $ADMIN_JWT"

# revoke by id (requires tenant:write + admin)
curl -s -X POST http://localhost:8080/api/v2/api-keys/$KEY_ID/revoke \
  -H "Authorization: Bearer $ADMIN_JWT"
```

`ListApiKeys` returns records without secrets or hashes — each record carries
`status`, `key_prefix`, `last_used_at`, `revoked_at`, and the roles/scopes/
environment scoping. Revocation flips the key to `revoked` immediately.

## Choosing between JWTs and API keys [#choosing-between-jwts-and-api-keys]

|               | OIDC JWT                               | DB-backed API key               |
| ------------- | -------------------------------------- | ------------------------------- |
| Best for      | Interactive users, federated identity  | Service-to-service, CI, scripts |
| Config        | `api.oidc_issuer`, `api.oidc_audience` | `auth.api_key_enabled=true`     |
| Tenant source | JWT claims                             | Stored key record               |
| Rotation      | IdP-managed                            | Revoke + reissue                |
| Header        | `Authorization: Bearer <jwt>`          | `X-API-Key: <secret>`           |

## Next steps [#next-steps]

<Cards>
  <Card title="Auth · API keys" href="/docs/auth/api-keys">
    The authentication model — verification, prefix/hash scheme, tenant + RBAC.
  </Card>

  <Card title="Management · API keys" href="/docs/management/api-keys">
    The full control-plane API — every field, filter, and lifecycle verb.
  </Card>

  <Card title="Security & auth" href="/docs/operations/security">
    OIDC, API keys, allowed hosts, and RBAC in depth.
  </Card>
</Cards>
