---
title: "UI & API Authentication - Pilot Docs"
description: "Configure OAuth2/OIDC authentication for the Pilot UI and API"
url: "https://docs.calinora.io/configuration/authentication/"
---

# UI & API Authentication

Pilot supports OAuth2/OIDC authentication to restrict access to the dashboard and API. When enabled, users must authenticate through a configured identity provider before accessing Pilot.

> **Note:** This page covers authentication for the **Pilot UI and API**. For Kafka broker authentication, see [SASL](https://docs.calinora.io/configuration/sasl/) and [OAuth for Kafka](https://docs.calinora.io/configuration/oauth/).

## Supported Providers

| Provider | Slug | Notes |
| - | - | - |
| Microsoft Entra ID | `entraid` | Supports tenant-based URL shortcuts |
| Google | `google` | Google Workspace / Cloud Identity |
| GitHub | `github` | GitHub OAuth Apps |
| OpenID Connect | `oidc` | Any standards-compliant OIDC provider (Keycloak, ADFS, Okta, Auth0) |

## Quick Start

### Entra ID (Recommended for Enterprise)

Entra ID has a convenience shortcut - set `AUTH_ENTRAID_TENANT` and Pilot auto-derives the auth, token, and issuer URLs:

```bash
AUTH_ENTRAID_ENABLED=true
AUTH_ENTRAID_TENANT=your-tenant-id
AUTH_ENTRAID_CLIENT_ID=your-app-registration-client-id
AUTH_ENTRAID_CLIENT_SECRET=your-client-secret
AUTH_COOKIE_SECRET=random-32-char-secret
```

### Google

```bash
AUTH_GOOGLE_ENABLED=true
AUTH_GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
AUTH_GOOGLE_CLIENT_SECRET=your-client-secret
AUTH_GOOGLE_AUTH_URL=https://accounts.google.com/o/oauth2/v2/auth
AUTH_GOOGLE_TOKEN_URL=https://oauth2.googleapis.com/token
AUTH_GOOGLE_API_URL=https://openidconnect.googleapis.com/v1/userinfo
AUTH_COOKIE_SECRET=random-32-char-secret
```

Google access tokens are opaque, so `AUTH_GOOGLE_ISSUER` is deliberately absent above. Setting it makes Pilot verify the `id_token` signature at browser login, but it also turns off bearer auth for every Google access token, since Pilot then binds credentials by audience and an opaque token declares none. Add it only if no Google bearer client (MCP, CI) authenticates against this deployment. See the callout under [Bearer Token Validation](https://docs.calinora.io/configuration/authentication/#bearer-token-validation) for why the two are mutually exclusive.

### GitHub

```bash
AUTH_GITHUB_ENABLED=true
AUTH_GITHUB_CLIENT_ID=your-github-oauth-app-client-id
AUTH_GITHUB_CLIENT_SECRET=your-client-secret
AUTH_GITHUB_AUTH_URL=https://github.com/login/oauth/authorize
AUTH_GITHUB_TOKEN_URL=https://github.com/login/oauth/access_token
AUTH_GITHUB_API_URL=https://api.github.com/user
AUTH_COOKIE_SECRET=random-32-char-secret
```

### OpenID Connect (Keycloak, ADFS, Okta, Auth0)

The `oidc` provider is vendor-neutral - point it at any standards-compliant OIDC issuer. Keycloak:

```bash
AUTH_OIDC_ENABLED=true
AUTH_OIDC_NAME=Keycloak
AUTH_OIDC_CLIENT_ID=pilot
AUTH_OIDC_CLIENT_SECRET=your-client-secret
AUTH_OIDC_AUTH_URL=https://keycloak.example.com/realms/your-realm/protocol/openid-connect/auth
AUTH_OIDC_TOKEN_URL=https://keycloak.example.com/realms/your-realm/protocol/openid-connect/token
AUTH_OIDC_API_URL=https://keycloak.example.com/realms/your-realm/protocol/openid-connect/userinfo
AUTH_OIDC_ISSUER=https://keycloak.example.com/realms/your-realm
AUTH_COOKIE_SECRET=random-32-char-secret
```

`AUTH_OIDC_NAME` sets the label on the login button. The callback URL Pilot advertises is `https://<pilot-host>/api/v1/auth/callback/oidc` - register that exact URI with your provider.

Keycloak access tokens often carry an `aud` of `account` and the client id in `azp`. Pilot accepts either, so no extra setting is needed for the common case. If your realm maps a dedicated resource-server audience onto Pilot’s tokens, list it explicitly:

```bash
AUTH_OIDC_ALLOWED_AUDIENCES=pilot-api
```

#### AD FS

AD FS 2016 or newer (earlier versions are WS-Federation/SAML only, which Pilot does not support):

```bash
AUTH_OIDC_ENABLED=true
AUTH_OIDC_NAME=ADFS
AUTH_OIDC_CLIENT_ID=<adfs-client-id>
AUTH_OIDC_CLIENT_SECRET=<adfs-client-secret>
AUTH_OIDC_AUTH_URL=https://adfs.example.com/adfs/oauth2/authorize
AUTH_OIDC_TOKEN_URL=https://adfs.example.com/adfs/oauth2/token
AUTH_OIDC_ISSUER=https://adfs.example.com/adfs
AUTH_COOKIE_SECRET=random-32-char-secret
```

Take the exact `issuer`, `authorization_endpoint`, and `token_endpoint` values from `https://<adfs-host>/adfs/.well-known/openid-configuration`; the issuer is compared exactly. Register the AD FS application as a **Server application** so it accepts a client secret in the token request body.

On AD FS 2016, also set `AUTH_OIDC_USE_PKCE=false` - PKCE arrived in AD FS 2019, and older versions reject the `code_verifier` parameter.

AD FS claim names depend on the relying party’s issuance transform rules - there is no built-in `groups` claim, and the userinfo endpoint returns only `sub`, so Pilot reads claims from the `id_token`. Group membership commonly arrives under `roles` or `role` (Token-Groups mapped to the Role claim type). Decode a token and point the mapping at what your rules issue:

```bash
AUTH_OIDC_GROUPS_CLAIM=roles
AUTH_OIDC_USERNAME_CLAIM=upn
```

See [Claim Mapping](https://docs.calinora.io/configuration/authentication/#claim-mapping) for the defaults.

## Per-Provider Configuration

All providers use the same configuration pattern with the `AUTH_<PROVIDER>_*` prefix. Replace `<PROVIDER>` with `ENTRAID`, `GOOGLE`, `GITHUB`, or `OIDC`.

`AUTH_<PROVIDER>_ENABLED` must be present in the environment for the rest of that provider’s block to be read at all. Setting only `AUTH_OIDC_CLIENT_ID`, for example, configures nothing.

| Variable | Default | Description |
| - | - | - |
| `AUTH_<PROVIDER>_ENABLED` | `false` | Enable this provider |
| `AUTH_<PROVIDER>_NAME` | Auto | Display name on login page |
| `AUTH_<PROVIDER>_CLIENT_ID` | `""` | OAuth client ID |
| `AUTH_<PROVIDER>_CLIENT_SECRET` | `""` | OAuth client secret |
| `AUTH_<PROVIDER>_AUTH_URL` | `""` | Authorization endpoint |
| `AUTH_<PROVIDER>_TOKEN_URL` | `""` | Token endpoint |
| `AUTH_<PROVIDER>_API_URL` | `""` | Userinfo endpoint |
| `AUTH_<PROVIDER>_ISSUER` | `""` | OIDC issuer. Enables JWKS discovery and bearer JWT signature verification |
| `AUTH_<PROVIDER>_JWKS_URL` | `""` | JWKS endpoint. Overrides discovery from the issuer, but still requires it. Must be an absolute `http`/`https` URL; anything else is ignored |
| `AUTH_<PROVIDER>_JWKS_CACHE_TTL` | `15m` | How long fetched signing keys are cached |
| `AUTH_<PROVIDER>_SCOPES` | `openid email profile` | OAuth scopes (space or comma-separated) |
| `AUTH_<PROVIDER>_USE_PKCE` | `true` | Enable PKCE for authorization code flow |
| `AUTH_<PROVIDER>_USE_REFRESH_TOKEN` | `true` | Enable refresh token handling |
| `AUTH_<PROVIDER>_ALLOW_SIGN_UP` | `true` | Allow new users to register |
| `AUTH_<PROVIDER>_AUTO_LOGIN` | `false` | Auto-redirect to provider (skip login page) |
| `AUTH_<PROVIDER>_USERNAME_CLAIM` | `""` | Claim for the session username. See [Claim Mapping](https://docs.calinora.io/configuration/authentication/#claim-mapping) |
| `AUTH_<PROVIDER>_EMAIL_CLAIM` | `""` | Claim for the session email and `ALLOWED_DOMAINS`. See [Claim Mapping](https://docs.calinora.io/configuration/authentication/#claim-mapping) |
| `AUTH_<PROVIDER>_GROUPS_CLAIM` | `""` | Claim for session groups and `ALLOWED_GROUPS`. See [Claim Mapping](https://docs.calinora.io/configuration/authentication/#claim-mapping) |

### Claim Mapping

Pilot builds the session identity (username, email, groups) from the claims the provider returns. Each field has a default chain of claim names, tried in order; the first one present with a non-empty value wins.

| Field | Variable | Default chain | Used by |
| - | - | - | - |
| Username (UPN) | `AUTH_<PROVIDER>_USERNAME_CLAIM` | `upn`, `preferred_username` | Session username |
| Email | `AUTH_<PROVIDER>_EMAIL_CLAIM` | `email`, `preferred_username`, `upn` | Session email, `AUTH_<PROVIDER>_ALLOWED_DOMAINS` |
| Groups | `AUTH_<PROVIDER>_GROUPS_CLAIM` | `groups`, `roles`, `role`, `group` | Session groups, `AUTH_<PROVIDER>_ALLOWED_GROUPS` |

A configured claim is consulted first, with the default chain as fallback. The groups claim accepts an array or a single string; lists are never merged across claim names.

Claims come from both the userinfo response and the `id_token` (userinfo wins; the `id_token` fills in what is missing), so a provider whose userinfo returns only `sub` still yields a full identity. Make sure the IdP emits the claim you filter on into the token; see [Access Control](https://docs.calinora.io/configuration/authentication/#access-control).

## Bearer Token Validation

API clients that cannot hold a session cookie authenticate with `Authorization: Bearer <token>`. Pilot accepts a bearer credential in exactly two ways:

1. **Signed JWT** - verified against the signing keys the provider publishes.
2. **Opaque token** - presented to `AUTH_<PROVIDER>_API_URL`. The response is the identity; if the provider does not answer with a claim set, the request is rejected.

There is no third path. A bearer token’s own payload is never an identity: a token whose signature cannot be checked, and that no userinfo endpoint vouches for, is rejected with `401`.

> [!WARNING]
>
> **Opaque tokens require a provider that cannot verify JWTs.** A userinfo response names a user, never the client the token was minted for, so Pilot cannot tell one application’s opaque token from another’s. Where a provider publishes an issuer, Pilot binds credentials by audience instead, and an opaque token from that provider is rejected with `403 invalid audience`. Accepting it would let any token the issuer minted for any other client authenticate, which would make the audience check optional rather than mandatory.
>
> In practice: setting `AUTH_<PROVIDER>_ISSUER` turns off opaque-token bearer auth for that provider. Browser login and JWT bearer auth are unaffected. GitHub publishes no issuer, so its tokens are always authenticated through userinfo.

| Provider | Access token shape | Required for bearer auth |
| - | - | - |
| Entra ID | JWT (`aud` is `api://<client-id>`) | `AUTH_ENTRAID_TENANT` or `AUTH_ENTRAID_ISSUER` |
| OpenID Connect | JWT | `AUTH_OIDC_ISSUER` |
| Google | opaque (`ya29.*`) | `AUTH_GOOGLE_API_URL`, and `AUTH_GOOGLE_ISSUER` left unset |
| GitHub | opaque (`gho_*`, `ghp_*`) | `AUTH_GITHUB_API_URL` |

### What Is Checked on a Signed JWT

| Check | Rule |
| - | - |
| Signature | Must match a key the provider publishes at its JWKS endpoint |
| Algorithm | `RS*`, `PS*`, `ES*` only. `none` and the HMAC family are rejected |
| `exp` | Required. A token without `exp` is rejected. 60s clock leeway |
| `nbf`, `iat` | Validated when present, same leeway |
| `iss` | Must match `AUTH_<PROVIDER>_ISSUER`. A trailing slash on either side is ignored |
| `aud`, `azp` | Must name this deployment (see [Audience Binding](https://docs.calinora.io/configuration/authentication/#audience-binding)) |

Signing keys are discovered at `<issuer>/.well-known/openid-configuration`, cached for `AUTH_<PROVIDER>_JWKS_CACHE_TTL` (default `15m`), and re-fetched when a token names an unknown `kid`, so key rotation needs no restart. Discovery and JWKS fetches will not follow a redirect to a different host.

**Signing keys being unavailable is a rejection, never a downgrade.** A provider configured to verify signatures does not fall back to an unverified token body when its JWKS endpoint is unreachable. Last-known-good keys keep serving for up to 30 minutes past the cache TTL so a transient outage does not lock everyone out; past that window, bearer JWTs for that provider are rejected until the keys are reachable again.

### Audience Binding

A valid signature and a matching issuer prove who signed a token, not who it was for. An issuer that signs tokens for several applications (a shared Entra tenant, a multi-client Keycloak realm) would otherwise let a token minted for a different application authenticate here. Pilot therefore requires every bearer JWT to name this deployment, and fails closed when it cannot.

- `AUTH_<PROVIDER>_ALLOWED_AUDIENCES` defaults to `AUTH_<PROVIDER>_CLIENT_ID` when it is not set. For Entra ID the default is `api://<client-id>` and `<client-id>`.
- Both `aud` (a string or an array) and `azp` are examined. A Keycloak token whose `aud` names a resource server still authenticates when `azp` is the Pilot client id; a token whose `azp` names a different application is rejected.
- A token that names none of the expected audiences is rejected with `403` and the message `invalid audience`.
- A provider that resolves no audience at all (no client id and no allowlist) rejects every bearer JWT with `401`.
- **A configured allowlist is always enforced.** Pilot distinguishes an allowlist it derived from the client id from one you set in `AUTH_<PROVIDER>_ALLOWED_AUDIENCES`. The derived form is waived for a provider that cannot verify JWTs, because a userinfo response names a user and never an audience, so nothing could ever satisfy it. A value you configured is a stated requirement and is never waived, even for such a provider, and even when it happens to equal the value Pilot would have derived. Setting `AUTH_GITHUB_ALLOWED_AUDIENCES` therefore rejects every GitHub bearer token with `403 invalid audience`: GitHub declares no audience, so the requirement cannot be met. Leave it unset for userinfo-only providers.

## Access Control

Restrict access by organization, email domain, group, or audience:

```bash
AUTH_ENTRAID_ALLOWED_ORGANIZATIONS=your-tenant-id
AUTH_ENTRAID_ALLOWED_DOMAINS=example.com,corp.example.com
AUTH_ENTRAID_ALLOWED_GROUPS=kafka-admins,platform-team
AUTH_ENTRAID_ALLOWED_AUDIENCES=api://your-client-id
```

| Variable | Description |
| - | - |
| `AUTH_<PROVIDER>_ALLOWED_ORGANIZATIONS` | Allowed organizations (comma-separated) |
| `AUTH_<PROVIDER>_ALLOWED_DOMAINS` | Allowed email domains (comma-separated) |
| `AUTH_<PROVIDER>_ALLOWED_GROUPS` | Allowed groups (comma-separated) |
| `AUTH_<PROVIDER>_ALLOWED_AUDIENCES` | Allowed token audiences (comma-separated). Defaults to `AUTH_<PROVIDER>_CLIENT_ID` (Entra ID also accepts `api://<client-id>`) so a bearer JWT must be minted for this deployment. |

If any filter is set, users must match at least one value in that filter to be granted access. All four filters **fail closed**: a token that carries no claim the filter can read (for example a domain restriction against a token with no email) is rejected, not waved through. A failed filter returns `403` with the reason (`organization not allowed`, `email domain not allowed`, `group not allowed`, `invalid audience`).

The filters run on browser login and on bearer authentication, always against claims that were verified or returned by the provider, never against values read out of an unverified token body. For a **signed JWT** those are the token’s own verified claims (`groups`, `email`, `tid`, `aud`); a group or domain the provider exposes only on its userinfo endpoint is not consulted, so make sure the IdP emits the claim you filter on into the token itself. PATs are not subject to these filters.

The group and domain filters read the claim names configured by `AUTH_<PROVIDER>_GROUPS_CLAIM` and `AUTH_<PROVIDER>_EMAIL_CLAIM`, falling back to the default chains described under [Claim Mapping](https://docs.calinora.io/configuration/authentication/#claim-mapping). A provider that issues groups under a name outside the default chain needs `AUTH_<PROVIDER>_GROUPS_CLAIM` set for `ALLOWED_GROUPS` to see them. For AD FS, which issues them under `roles`, `AUTH_<PROVIDER>_GROUPS_CLAIM=roles` makes the mapping explicit and consults `roles` ahead of any `groups` claim the token may also carry.

## Session Settings

| Variable | Default | Description |
| - | - | - |
| `AUTH_COOKIE_SECRET` | `""` | HMAC signing key for session cookies. **Required in production.** Auto-generated in dev if empty. |
| `AUTH_COOKIE_NAME` | `pilot_session` | Session cookie name |
| `AUTH_COOKIE_DOMAIN` | `""` | Cookie domain scope |
| `AUTH_COOKIE_SECURE` | `true` | HTTPS-only cookies. Set to `false` for HTTP-only dev environments. |
| `AUTH_SESSION_TTL` | `12h` | Session time-to-live |

## Debug Options

| Variable | Default | Description |
| - | - | - |
| `AUTH_DEBUG_EXPOSE_TOKENS` | `false` | Expose raw access tokens in `/api/v1/auth/me` response. **Never enable in production.** |

## API Endpoints

| Method | Path | Description |
| - | - | - |
| `GET` | `/api/v1/auth/providers` | List configured OAuth providers |
| `GET` | `/api/v1/auth/login/{provider}` | Initiate OAuth flow |
| `GET` | `/api/v1/auth/callback/{provider}` | OAuth callback |
| `GET` | `/api/v1/auth/me` | Current authenticated user |
| `POST` | `/api/v1/auth/logout` | End session |
| `POST` | `/api/v1/auth/refresh` | Refresh access token |

## Entra ID Convenience

When `AUTH_ENTRAID_TENANT` is set, Pilot automatically derives:

- `AUTH_URL` → `https://login.microsoftonline.com/<tenant>/oauth2/v2.0/authorize`
- `TOKEN_URL` → `https://login.microsoftonline.com/<tenant>/oauth2/v2.0/token`
- `ISSUER` → `https://login.microsoftonline.com/<tenant>/v2.0`
- `API_URL` → `https://graph.microsoft.com/oidc/userinfo`
- `ALLOWED_ORGANIZATIONS` → `[<tenant>]` (if not explicitly set)
- `ALLOWED_AUDIENCES` → `[api://<client-id>, <client-id>]` (if not explicitly set)

You can override any of these by setting the explicit variable.

Every other provider (Google, GitHub, OIDC) also defaults `ALLOWED_AUDIENCES` to `[<client-id>]` when it is not set, so a bearer JWT signed by the trusted issuer for a different application is rejected rather than accepted on issuer alone.

## Personal Access Tokens (PATs)

When authentication is enabled, any programmatic access to the Pilot API requires authentication. Personal Access Tokens provide long-lived, revocable Bearer tokens that work with every API endpoint - REST calls, CI/CD pipelines, monitoring integrations, and MCP clients alike.

### How It Works

1. A user authenticates via OAuth in the Pilot UI
2. From the user menu, they create a PAT with a name, scope, and optional expiry
3. The plaintext token (`pat_...`) is shown once and must be copied immediately
4. The token is used as a Bearer token: `Authorization: Bearer pat_...`

Only the HMAC-SHA256 hash of the token is stored - the plaintext is never persisted.

### Token Scopes

| Scope | Permissions |
| - | - |
| `read` | GET operations only (monitoring, viewing configs, browsing messages) |
| `write` | All operations including mutations (reassignments, ACLs, quotas, etc.) |

### Configuration

| Variable | Default | Description |
| - | - | - |
| `AUTH_PAT_ENABLED` | `false` | Enable PAT feature (requires auth to be enabled) |
| `AUTH_PAT_HASH_SECRET` | `""` | **Required.** HMAC-SHA256 secret for hashing tokens. Must be a stable, persistent value - PATs become invalid if this changes. |
| `AUTH_PAT_MAX_PER_USER` | `10` | Maximum tokens per user |

> **Important:** Both `AUTH_PAT_ENABLED=true` and a non-empty `AUTH_PAT_HASH_SECRET` are required for PATs to work. If the hash secret is not set, PAT endpoints will return a “not enabled” error even when `AUTH_PAT_ENABLED=true`.

### API Endpoints

| Method | Path | Description |
| - | - | - |
| `GET` | `/api/v1/tokens` | List your tokens |
| `POST` | `/api/v1/tokens` | Create a new token |
| `DELETE` | `/api/v1/tokens/{id}` | Revoke a token |

> **Security:** PAT management endpoints require an OAuth session. A PAT cannot create or revoke other PATs, preventing token-chaining.

### Usage

Include the token as a Bearer token in any API request:

```bash
curl https://pilot.example.com/api/v1/cluster \
  -H "Authorization: Bearer pat_your_token_here"
```

All API endpoints accept PAT authentication - cluster info, partitions, consumer groups, proposals, ACLs, quotas, and more. See the [API Reference](https://docs.calinora.io/api/reference/) for the full endpoint list.

### Use Cases

- **REST API access** - authenticate any API call from scripts, tools, or applications
- **CI/CD pipelines** - automate Kafka operations (topic config updates, ACL management, reassignments)
- **Monitoring integrations** - read-only tokens for external dashboards pulling cluster metrics
- **MCP clients** (GitHub Copilot, Warp, Claude Desktop) - see [MCP Authentication](https://docs.calinora.io/features/mcp/#authentication) for client configuration examples
- **Service accounts** - long-lived access for automation tools
