# Configure agent authentication

Add one or more validators under `spec.externalAuth` so that your applications can call an agent. For how the validator chain works, see [Authentication architecture](/explanation/security/authentication/#agent-authentication). For every field, see [AgentRuntime: externalAuth](/reference/core/agentruntime/#externalauth). For dashboard sign-in, see [Configure dashboard authentication](/how-to/security/configure-dashboard-auth/).

With no `spec.externalAuth`, the external listener returns 401 to every request. Only the dashboard's "Try this agent" view reaches the agent, over the internal management twin.

## Prerequisites

- An AgentRuntime with at least one facade, for example `my-agent` in namespace `my-namespace`.
- For client keys: a checkout of the Omnia repository (for `hack/mint-client-key.sh`), `kubectl` and `openssl`.
- For OIDC: an identity provider whose discovery document the operator can reach.
- For edge-trust: an edge that verifies the JWT and injects claim headers.

## Choose validators

Use `clientKeys` for a few known applications, `oidc` for end users who hold tokens from your identity provider, and `edgeTrust` when a gateway or mesh already verifies the JWT. You can enable all three. The facade tries them in that order and admits the first that accepts the request. A validator that recognises a credential and rejects it, such as an expired JWT, ends the chain.

## Add client keys

1. Enable the validator. An empty object is enough:

   ```yaml
   spec:
     externalAuth:
       clientKeys: {}
   ```

   Apply it with `kubectl apply -f`. Add `defaultRole` or `trustEndUserHeader` under `clientKeys` if you need them.

2. Mint a key:

   ```bash
   hack/mint-client-key.sh \
     --context my-cluster \
     --namespace my-namespace \
     --agent my-agent \
     --id terraform \
     --role premium \
     --expires-at 2027-01-01T00:00:00Z
   ```

   The script requires `--context`. Add `--claim key=value` for further claims. It prints the raw token to stdout once. The cluster stores only the SHA-256 hash, so capture the token or mint another key.

3. Give the token to the caller. The caller sends `Authorization: Bearer <token>`.

4. To revoke a key, delete its Secret. No AgentRuntime change is needed:

   ```bash
   kubectl delete secret agent-my-agent-clientkey-terraform -n my-namespace
   ```

The key's claims reach ToolPolicy as `principal.claims`. A key minted with `--role premium` is matched by `principal.claims has role && principal.claims.role == "premium"`. A key with no claims gets `role` set to `defaultRole`. See [Configure tool policies](/how-to/security/configure-tool-policies/).

To create the Secret without the script, follow the [client key Secret contract](/reference/core/agentruntime/#client-key-secret). The facade skips a Secret whose `ownerReferences` do not name the AgentRuntime's UID, and one whose `keyHash` is not 32 raw bytes. Each skip is logged.

## Add OIDC

1. Point the facade at your issuer:

   ```yaml
   spec:
     externalAuth:
       oidc:
         issuer: "https://auth.example.com"
         audience: "my-agent"
   ```

   `issuer` must equal the `iss` claim in your tokens exactly. These are common issuer shapes. Confirm yours in the provider's discovery document.

   | Provider | `issuer` |
   |----------|----------|
   | Auth0 | `https://<tenant>.auth0.com/` |
   | Okta | `https://<org>.okta.com/oauth2/default` |
   | Google | `https://accounts.google.com` |
   | Keycloak | `https://<host>/realms/<realm>` |
   | Azure AD | `https://login.microsoftonline.com/<tenant-id>/v2.0` |

2. Wait for the controller to mirror the JWKS, then check the condition:

   ```bash
   kubectl get agentruntime my-agent -n my-namespace \
     -o jsonpath='{.status.conditions[?(@.type=="OIDCJWKSReady")]}'
   ```

   The controller creates the Secret `agent-my-agent-oidc-jwks` and refreshes it every six hours. The facade verifies tokens in-process, so no service mesh is needed.

The IdP's role and group claims reach ToolPolicy under their own names, for example `principal.claims.groups`. `claimMapping` renames only the subject and end-user claims.

A WebSocket connection admitted by an OIDC token closes with code `4401` shortly before the token's `exp`. To keep it open, send a new token in a `credential_refresh` frame before then. See [WebSocket protocol](/reference/platform/websocket-protocol/#credential-refresh).

### Bind tokens to one conversation

If your issuer mints a token per conversation, name the claim that identifies the conversation:

```yaml
spec:
  externalAuth:
    oidc:
      issuer: "https://auth.example.com"
      audience: "my-agent"
      sessionClaim: diagnostic_session_id
```

The facade then refuses a token without the claim, and a token can resume only a session that was started with the same claim value. This applies to WebSocket sessions and A2A contexts. An A2A client starts a conversation by sending no `contextId`, because a `contextId` the facade has not recorded is refused. The check needs session-api. Without it, every resume is refused. A session that session-api could not record, which the facade then runs unrecorded, cannot be resumed by a bound client either.

## Add edge-trust

1. Name the headers your edge injects. The defaults match the chart's Istio layout:

   ```yaml
   spec:
     externalAuth:
       edgeTrust:
         headerMapping:
           subject: x-user-id
           endUser: x-user-id
           email: x-user-email
         claimsFromHeaders:
           x-user-groups: groups
   ```

   A request is admitted when the subject header is present. The role header `x-user-roles` is always read into the `role` claim. To expose other headers, list them in `claimsFromHeaders`.

2. Make the edge strip inbound copies of every header in `headerMapping` and `claimsFromHeaders`.

   :::danger
   If the edge forwards a caller's own copy of these headers, that caller can claim any identity. With `authentication.enabled` and `istio.enabled`, the chart's EnvoyFilter strips `authentication.edgeTrust.defaultStripHeaders`, the headers in `authentication.jwt.outputClaimToHeaders` and `authentication.edgeTrust.additionalStripHeaders`. It applies only to agent pods in the release namespace. For an agent in a workspace namespace, or behind any other edge, configure the strip yourself.
   :::

## Keep the dashboard debug view

`managementPlane` is set per facade and defaults to `true`. Set it to `false` on a facade to remove its management twin, which blocks the dashboard debug view for that facade:

```yaml
spec:
  facades:
    - type: websocket
      managementPlane: false
```

## Connect with a token

The facade reads the credential only from the `Authorization` header of the WebSocket upgrade request. A browser's `WebSocket` constructor cannot set that header, so browser callers connect through a server-side proxy.

```bash
websocat -H "Authorization: Bearer $TOKEN" \
  "wss://<agent-host>/ws?agent=my-agent"
```

```javascript
import WebSocket from 'ws';

const ws = new WebSocket('wss://<agent-host>/ws?agent=my-agent', {
  headers: { Authorization: `Bearer ${token}` },
});
```

See [Expose agents](/how-to/agents/expose-agents/) for the host.

## Verify

1. Confirm the `ExternalAuth` condition lists your validators:

   ```bash
   kubectl get agentruntime my-agent -n my-namespace \
     -o jsonpath='{.status.conditions[?(@.type=="ExternalAuth")].message}'
   ```

   The message has the form `facade admits: clientKeys, managementPlane`.

2. Connect without a token. The upgrade is refused with 401.
3. Connect with a valid token. The facade sends a `connected` message.

## Troubleshooting

### Every request returns 401

The facade logs the first rejection of each cause at the default level and repeats at debug level (`LOG_LEVEL=debug`, set through `spec.facades[].extraEnv`):

```bash
kubectl logs -n my-namespace \
  -l app.kubernetes.io/name=omnia-agent,app.kubernetes.io/instance=my-agent \
  -c facade --tail=50
```

Look for `auth middleware rejected request`. The `reasonCode` field names the cause, and `remedy` says what to change:

| `reasonCode` | Cause |
|--------------|-------|
| `no_credential` | No `Authorization` header, or no validator recognised it. Also appears when a management token is sent to the external port |
| `expired` | The key or JWT has expired |
| `bad_signature`, `unknown_key`, `bad_algorithm`, `malformed_token` | The JWT does not verify against the JWKS. `oidc` accepts RS256 only |
| `bad_issuer`, `bad_audience` | The token's `iss` or `aud` differs from `issuer` or `audience` |
| `missing_session_claim` | The token lacks the claim that `oidc.sessionClaim` names |

### OIDC tokens are rejected

```bash
kubectl get secret agent-my-agent-oidc-jwks -n my-namespace
kubectl describe agentruntime my-agent -n my-namespace
```

If the Secret is missing, the `OIDCJWKSReady` condition gives the reason, for example `DiscoveryFailed`. Check that the operator pod can reach the issuer. The facade does not start with `oidc` enabled until the Secret exists. If tokens are still rejected, compare `issuer` with the `iss` claim character by character, including any trailing slash.

### Edge-trust claims are missing downstream

Tool handlers receive edge-trust claims as `X-Omnia-Claim-<Name>` headers, and ToolPolicy reads them as `principal.claims`. If they are missing:

1. Confirm the edge injects the expected headers, using a debug container or Envoy access logs.
2. Confirm the edge strips inbound copies of those headers.
3. Confirm `claimsFromHeaders` lists the header names as the edge emits them. Header names are case-insensitive.

## Related pages

- [Authentication architecture](/explanation/security/authentication/) explains the validator chain and the trust boundaries.
- [AgentRuntime CRD](/reference/core/agentruntime/) lists the `externalAuth` fields and the client key Secret.
- [Configure tool policies](/how-to/security/configure-tool-policies/) uses the admitted claims in ToolPolicy rules.
- [Expose agents](/how-to/agents/expose-agents/) routes external traffic to the facade.