---
title: "Gateway keys and workload identity"
---

# Gateway keys and workload identity

Give applications, workloads and coding tools a credential for the Neural Inverse Govern model gateway, from scoped gateway keys to keyless workload identity, short-lived gateway tokens and per-route authorizers.

Choose the credential by who is calling:

| Caller | Credential | Lifetime |
|---|---|---|
| An application or service with a deployed secret | Gateway key | Until it expires or is revoked |
| A workload that already has an identity (Kubernetes service account, CI job, Govern agent, client certificate) | Workload identity exchange | 60 to 3600 seconds |
| A coding tool used by a person | Gateway token | At most one hour |
| Traffic without a Govern credential, decided by your own rules | Authorizer | Per request |

## Gateway keys

A gateway key is an API key that can call the gateway and nothing else: it holds only `models:read`. Its secret is shown once, when it is created or rotated. The console page is **AI Gateway > Gateway keys**.

```bash
curl -sS -X POST "$KEYSTONE_URL/api/v1/models/gateway-keys" \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' \
  -d '{"name": "support-bot", "scope_kind": "project", "scope_id": "'$PROJECT_ID'"}'
```

| Setting | Meaning |
|---|---|
| `scope_kind`, `scope_id` | Who the key acts for: `org`, `team`, `project` or `user`. A project key may name only that project in `X-Keystone-Project`. A user key acts as the user but never uses their personal provider keys. The scope never changes. |
| `allowed_models`, `allowed_routes` | Narrow what the key may use. Empty allows everything its scope is assigned; a listed model still needs the assignment. Applied to the requested model, its fallbacks and every route target. |
| `budgets` | The key's own spend or token budgets, per day or month, hard or soft, on top of its scope's. They count across rotations. |
| `rate_limits` | The key's own request or token limits per window. |
| `ip_allowlist` | Client networks (CIDR or addresses). Other sources get `403 key_network_forbidden`. |
| `metadata` | Chargeback metadata stamped on every request; the key's values win over `X-Keystone-Metadata`. |
| `expires_at` | The key stops working then. |

### Rotate and revoke

`POST /api/v1/models/gateway-keys/{id}/rotate` with `{"overlap_seconds": 3600}` issues a new secret and lets the current one keep working for the overlap (0 to 604800 seconds, default 3600). The key's id, budgets, rate limits and analytics continue. `POST .../revoke` revokes every credential of the key at once.

At request time a revoked or expired key answers `401`, and a key whose team, project or user no longer exists answers `403 key_scope_unavailable`.

### Routes

| Route | Permission |
|---|---|
| `GET /api/v1/models/gateway-keys?kind=static` (or `workload`) | `models:view` |
| `POST /api/v1/models/gateway-keys` | `models:admin` |
| `GET /api/v1/models/gateway-keys/{id}` | `models:view` |
| `PATCH /api/v1/models/gateway-keys/{id}` | `models:admin` |
| `POST /api/v1/models/gateway-keys/{id}/rotate`, `/revoke` | `models:admin` |
| `GET /api/v1/models/gateway-keys/{id}/analytics` | `models:view` |

Every write is audited (`models.gateway_key.*`) without the secret.

### Per-key analytics

`GET /api/v1/models/gateway-keys/{id}/analytics?from=&to=&bucket=hour` (or `day`) reports a key's requests, errors, input and output tokens and cost over time, per model and per outcome. Hour buckets cover at most 31 days, day buckets 92. Requests refused before the budget step (authentication, the key's own limits, DLP, a rate limit) are in the request log and the audit trail, not here.

## Workload identity

A workload identity trust lets a workload exchange a credential it already has for a short-lived gateway key, so no static secret is deployed. The console page is **AI Gateway > Workload identity**.

| Kind | Accepts |
|---|---|
| `oidc` | A JWT of one issuer (a Kubernetes service account issuer, GitHub Actions, a cloud's workload identity). `iss` must equal `issuer` and `aud` must name one of `audiences`. The signature is checked against an uploaded key set (`jwks`, for issuers Govern cannot reach, such as an air-gapped cluster) or one fetched from `jwks_uri`. Tokens that live longer than a day are refused. |
| `agent` | A Govern agent's client credentials. Suspended, revoked or inactive agents are refused; `agent_ids` can limit the trust. |
| `mtls` | A client certificate issued by `ca_pem`, from the TLS connection or a header a TLS-terminating proxy sets (`NI_AUTH_MODELS_GATEWAY_CLIENT_CERT_HEADER`; set it only when the proxy always overwrites the header). |

**Rules** map workloads to what they get. The first enabled rule whose every `match` entry matches one of the claim's values (`*` matches any run of characters) decides the issued key's `scope_kind`, `scope_id`, `allowed_models`, `allowed_routes` and `metadata`. A workload no rule matches is refused. An OIDC rule needs at least one claim condition.

| Kind | Claims rules can match |
|---|---|
| `oidc` | The token's claims, nested by dotted path, for example `kubernetes.io.namespace` |
| `agent` | `agent_id`, `agent_name`, `client_id` |
| `mtls` | `subject.cn`, `subject.o`, `subject.ou`, `san.dns`, `san.uri`, `san.email`, `fingerprint` |

### The exchange

The exchange needs no Govern credential; the identity id names the trust.

```bash
curl -sS -X POST "$KEYSTONE_URL/api/v1/models/gateway/identity/token" \
  -H 'Content-Type: application/json' \
  -d '{"identity_id": "'$IDENTITY_ID'",
       "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
       "subject_token": "'"$(cat /var/run/secrets/tokens/gateway)"'",
       "subject_token_type": "urn:ietf:params:oauth:token-type:jwt"}'
```

The answer holds `access_token` (a `nia_` key), `expires_in` (60 to 3600 seconds, default 900), `gateway_key_id`, the scope and the rule that matched. The exchange also accepts an RFC 8693 form, and an agent's `client_id` and `client_secret` (or HTTP Basic).

Every refusal answers `400 invalid_grant` without saying why. The reason is in the organisation's audit trail (`models.gateway_identity.exchange`, outcome `failure`), and every issued key is audited too. Issued keys are listed with `kind=workload` and purged a week after they expire.

| Route | Permission |
|---|---|
| `GET`, `POST /api/v1/models/gateway-identities` | `models:view`, `models:admin` |
| `GET`, `PATCH`, `DELETE /api/v1/models/gateway-identities/{id}` | `models:view`, `models:admin` (deleting deletes the keys it issued) |

## Gateway tokens for coding tools

Coding tools send model requests as the person using them, with a short-lived gateway token instead of a long-lived key.

- A token starts with `nigw_`, lives at most one hour (5 to 60 minutes may be asked for), and is bound to the person's session or enrolled device.
- It is accepted only on the gateway's client endpoints under `/api/v1/models/gateway/v1/`; anywhere else it is refused with `401 gateway_token_route`.
- It carries the person's current permissions capped to `models:read`.
- Signing out of the session, retiring or reassigning the device, suspending the person or revoking the token with `POST /api/v1/auth/token/revoke` ends it.

| Route | Credential | Bound to |
|---|---|---|
| `POST /api/v1/models/gateway/tokens` | A person's access token (not an API key, not a root session) | The session |
| `POST /api/v1/agent/gateway-token` | The device token of an enrolled device assigned to a person | The device |

Both need the person to hold `models:read`. Each token issued, and each refused mint, is audited as `models.gateway_token.issued`.

People get a token from the command line:

```
keystone-cli token models [--tool T] [--ttl D] [--device] [--json] [--no-cache]
```

With `KEYSTONE_TOKEN` set it mints for the person; without it, or with `--device`, for the enrolled device. It caches the token per user until five minutes before expiry. Administrators distribute the `keystone-cli` binary; Govern publishes no download of it.

The console page **Models > Connect a coding tool** shows the configuration for each tool and mints a token for tools that take a pasted key. Per-tool steps are on the tool pages under [Third-party AI coding tools](https://neuralinverse.com/docs/govern/third-party-tools/).

## Authorizers

Authorizers decide who may call the gateway per route, like an API gateway's authorizers. The console page is **AI Gateway > Authorizers**.

| Type | Admits |
|---|---|
| `keystone_iam` | Callers Govern authenticated that hold `models:read`. The default when nothing is configured. |
| `jwt` | Bearers of a JWT of `jwt.issuer` for one of `jwt.audiences`, signed by an uploaded `jwt.jwks` or a key set from `jwt.jwks_uri`; `required_scopes` and `required_claims` must match. |
| `custom_http` | What your own https endpoint decides. Govern posts the request's identity sources and expects `{"allow": true, ...}`. Errors, timeouts and unreadable answers deny (`503 authorizer_unavailable`). |
| `custom_cel` | A CEL expression evaluated in process over the request and its identity sources. An error or the cost limit denies. |
| `none` | Callers without a credential. Govern refuses a `none` authorizer without a rate limit and a hard spend budget in its usage plan. |

Any type can also require a client certificate from a truststore (`mtls.ca_pem`) and HMAC-signed requests (`signing`, with the `X-Keystone-Signature` header). Traffic admitted without a Govern credential runs as the authorizer's own usage plan: its scope, models, budgets and rate limits.

`PUT /api/v1/models/gateway-authorization` sets the default authorizer and per-route overrides by endpoint id. On the in-server paths callers always hold a Govern credential, so the route's extra requirements (truststore, signed requests) still apply whatever the type. A route's authorizer is the only authentication on the public account edge, `/api/v1/models/edge/{org}/...`.

| Route | Permission |
|---|---|
| `GET`, `POST /api/v1/models/gateway-authorizers` | `models:view`, `models:admin` |
| `GET`, `PATCH`, `DELETE /api/v1/models/gateway-authorizers/{id}` | `models:view`, `models:admin` |
| `POST /api/v1/models/gateway-authorizers/{id}/test` | `models:admin` (the test console; nothing reaches a model) |
| `POST`, `DELETE /api/v1/models/gateway-authorizers/{id}/signing-keys` | `models:admin` |
| `GET`, `PUT /api/v1/models/gateway-authorization` | `models:view`, `models:admin` |

Every write and every test is audited (`models.gateway_authorizer.*`, `models.gateway_authorization.put`).

## Provider-native passthrough

Clients pinned to a provider's own API can use `POST /api/v1/models/gateway/anthropic/v1/messages` and `POST /api/v1/models/gateway/openai/v1/chat/completions` (also `/v1/completions`, `/v1/embeddings`, `/v1/responses`). They run through the same pipeline, reach only that provider's connections, and answer `403 passthrough_disabled` until an administrator turns them on:

```
PUT /api/v1/models/gateway-passthrough   (models:admin)
{"enabled": true, "anthropic_betas": [...], "openai_betas": [...]}
```

Beta values not listed are dropped, never forwarded. Beta families that make the provider act on its own (`mcp-client`, `code-execution`, `skills`, `files-api`, `web-fetch`) are refused on every path.
