# Dashboard authentication reference

Reference for the dashboard's authentication environment variables, OAuth providers, API routes, session and API-key formats, and role permissions.

management-api serves every sign-in route below and decides who each API request is from, from the request's own session cookie, proxy headers or API key. The dashboard is static files served by nginx: the Gateway or Ingress sends every `/api` path to management-api, and nginx proxies any that reaches it, with those credentials unchanged. The chart sets the variables below on management-api.

## Environment variables

### Core settings

| Variable | Description | Default |
|----------|-------------|---------|
| `OMNIA_AUTH_MODE` | Authentication mode: `anonymous`, `proxy`, `oauth`, or `builtin` | `anonymous` |
| `OMNIA_BASE_URL` | Base URL for building OAuth callback and redirect URLs | `http://localhost:3000` |

### Session configuration

| Variable | Description | Default |
|----------|-------------|---------|
| `OMNIA_SESSION_SECRET` | 32+ character secret for session encryption | Required in production for `oauth`, `builtin` and `proxy`; a fixed development constant otherwise (see below) |
| `OMNIA_SESSION_COOKIE_NAME` | Name of the session cookie | `omnia_session` |
| `OMNIA_SESSION_TTL` | Session lifetime in seconds | `86400` (24 hours) |

:::danger
In production (`NODE_ENV=production`, or inside any pod) and an auth mode of `oauth`, `builtin` or `proxy`, management-api refuses to start when `OMNIA_SESSION_SECRET` is unset, shorter than 32 characters, or equal to the development constant. Outside production, an unset secret falls back to that constant, a fixed string checked into the open-source tree that anyone can use to mint a session cookie.

The Helm chart generates a secret on first install, preserves it across upgrades, and wires it in.
:::

### Role mapping

| Variable | Description | Default |
|----------|-------------|---------|
| `OMNIA_AUTH_ROLE_ADMIN_GROUPS` | Comma-separated groups that get admin role | - |
| `OMNIA_AUTH_ROLE_EDITOR_GROUPS` | Comma-separated groups that get editor role | - |
| `OMNIA_AUTH_ANONYMOUS_ROLE` | Role for anonymous users | `viewer` |

### Proxy mode settings

| Variable | Description | Default |
|----------|-------------|---------|
| `OMNIA_AUTH_PROXY_HEADER_USER` | Header containing username | `X-Forwarded-User` |
| `OMNIA_AUTH_PROXY_HEADER_EMAIL` | Header containing email | `X-Forwarded-Email` |
| `OMNIA_AUTH_PROXY_HEADER_GROUPS` | Header containing groups (comma-separated) | `X-Forwarded-Groups` |
| `OMNIA_AUTH_PROXY_HEADER_DISPLAY_NAME` | Header containing display name | `X-Forwarded-Preferred-Username` |

### OAuth mode settings

| Variable | Description | Default |
|----------|-------------|---------|
| `OMNIA_OAUTH_PROVIDER` | Provider type (see below) | `generic` |
| `OMNIA_OAUTH_CLIENT_ID` | OAuth client ID | - |
| `OMNIA_OAUTH_CLIENT_SECRET` | OAuth client secret | - |
| `OMNIA_OAUTH_CLIENT_SECRET_FILE` | Path to file containing client secret | - |
| `OMNIA_OAUTH_ISSUER_URL` | OIDC issuer URL (required for generic) | - |
| `OMNIA_OAUTH_SCOPES` | Comma-separated OAuth scopes. Replaces the provider's scope list entirely | Provider's scopes (see [OAuth providers](#oauth-providers)) |

### OAuth claim mapping

Each variable overrides one claim; any claim left unset uses the provider's mapping.

| Variable | Description | Default |
|----------|-------------|---------|
| `OMNIA_OAUTH_CLAIM_USERNAME` | Claim for username | `preferred_username` (`email` for `google`) |
| `OMNIA_OAUTH_CLAIM_EMAIL` | Claim for email | `email` |
| `OMNIA_OAUTH_CLAIM_DISPLAY_NAME` | Claim for display name | `name` |
| `OMNIA_OAUTH_CLAIM_GROUPS` | Claim for groups | `groups` |

The Helm chart sets these variables only when the matching `dashboard.oauth.scopes` or
`dashboard.oauth.claims.*` value is non-empty, and all of them default to empty.

### Provider-specific settings

| Variable | Description | Default |
|----------|-------------|---------|
| `OMNIA_OAUTH_AZURE_TENANT_ID` | Azure AD tenant ID | `common` |
| `OMNIA_OAUTH_OKTA_DOMAIN` | Okta organization domain (required unless `OMNIA_OAUTH_ISSUER_URL` is set) | - |

### API keys

| Variable | Description | Default |
|----------|-------------|---------|
| `OMNIA_AUTH_API_KEYS_ENABLED` | Enable API key authentication | `true` |
| `OMNIA_AUTH_API_KEYS_MAX_PER_USER` | Maximum keys per user | `10` |
| `OMNIA_AUTH_API_KEYS_DEFAULT_EXPIRATION` | Default expiration in days (0 = never) | `90` |

### Builtin mode settings

| Variable | Description | Default |
|----------|-------------|---------|
| `POSTGRES_CONN` | management-api's own PostgreSQL database, which holds the builtin users. Required in builtin mode | - |
| `OMNIA_BUILTIN_POSTGRES_URL` | The database a `postgres` API-key store falls back to when `dashboard.apiKeys` names none | - |
| `OMNIA_BUILTIN_ALLOW_SIGNUP` | Allow public user registration | `false` |
| `OMNIA_BUILTIN_VERIFY_EMAIL` | Require email verification | `false` |
| `OMNIA_BUILTIN_MIN_PASSWORD_LENGTH` | Minimum password length | `8` |
| `OMNIA_BUILTIN_MAX_FAILED_ATTEMPTS` | Failed logins before lockout | `5` |
| `OMNIA_BUILTIN_LOCKOUT_DURATION` | Lockout duration in seconds | `900` |
| `OMNIA_BUILTIN_RESET_TOKEN_EXPIRATION` | Password reset token TTL (seconds) | `3600` |
| `OMNIA_BUILTIN_VERIFICATION_TOKEN_EXPIRATION` | Email verification token TTL (seconds) | `86400` |
| `OMNIA_BUILTIN_ADMIN_USERNAME` | Initial admin username | - |
| `OMNIA_BUILTIN_ADMIN_EMAIL` | Initial admin email | - |
| `OMNIA_BUILTIN_ADMIN_PASSWORD` | Initial admin password | - |

management-api reads every variable in this table. The dashboard's pages learn `OMNIA_BUILTIN_ALLOW_SIGNUP`, which opens `/signup`, and `OMNIA_BUILTIN_MIN_PASSWORD_LENGTH`, which the signup and reset forms show, from management-api's `GET /api/v1/config`; a length that is not a whole number is `8` there too. The dashboard keeps no user store.

Admin seeding runs at management-api's startup only when its user table is empty and all three of `OMNIA_BUILTIN_ADMIN_USERNAME`, `OMNIA_BUILTIN_ADMIN_EMAIL` and `OMNIA_BUILTIN_ADMIN_PASSWORD` are set. The Helm chart sets all three only when `dashboard.auth.mode` is `builtin`, and the first two default to `admin` and `admin@example.com`.

### Password reset and verification email

management-api sends reset and verification links through SMTP. With `OMNIA_SMTP_HOST` unset, it sends nothing and logs a warning.

Both links start with `OMNIA_BASE_URL`. A reset link opens `/reset-password?token=…`, where the user sets a new password. A verification link opens `/verify-email?token=…`: the user selects **Verify email**, and the page sends the token to `POST /api/v1/auth/builtin/verify-email`. Neither page sends a `Referer` header, because its URL holds the token.

Besides `/login`, the builtin sign-in pages need no session: `/signup`, `/forgot-password`, `/reset-password` and `/verify-email`. Each one redirects to `/` outside builtin mode. `/signup` also redirects to `/login` when `OMNIA_BUILTIN_ALLOW_SIGNUP` is off, and sends a signed-in user to `returnTo` when it is a path on this origin, otherwise to `/`.

| Variable | Description | Default |
|----------|-------------|---------|
| `OMNIA_SMTP_HOST` | SMTP server host | - |
| `OMNIA_SMTP_PORT` | SMTP server port | `587` |
| `OMNIA_SMTP_SECURE` | `true` for implicit TLS | `false` |
| `OMNIA_SMTP_FROM` | Sender address | `noreply@omnia.local` |
| `OMNIA_SMTP_USER` / `OMNIA_SMTP_PASS` | SMTP credentials; sent only when both are set | - |

## Helm values

The chart renders the variables above from these values. Variables with no value here go in extra environment: the builtin token lifetimes in `managementApi.podOverrides.extraEnv`, and `OMNIA_CSP_POLICY`, which nginx reads, in `dashboard.extraEnv` or `dashboard.extraEnvFrom`.

| Variable | Helm value |
|----------|------------|
| `OMNIA_AUTH_MODE` | `dashboard.auth.mode` (no default; the chart fails when it is empty) |
| `OMNIA_ALLOW_ANONYMOUS` | `dashboard.auth.allowAnonymous` (must be `true` with `anonymous` mode) |
| `OMNIA_AUTH_ANONYMOUS_ROLE` | `dashboard.auth.anonymousRole` |
| `OMNIA_SESSION_COOKIE_NAME` / `OMNIA_SESSION_TTL` | `dashboard.auth.cookieName` / `dashboard.auth.ttl` |
| `OMNIA_AUTH_ROLE_ADMIN_GROUPS` / `OMNIA_AUTH_ROLE_EDITOR_GROUPS` | `dashboard.auth.roleMapping.adminGroups` / `.editorGroups` (lists) |
| `OMNIA_BASE_URL` | `dashboard.baseUrl`, else derived from `dashboard.ingress.host` and `dashboard.ingress.tls` |
| `OMNIA_AUTH_PROXY_HEADER_USER` / `_EMAIL` / `_GROUPS` / `_DISPLAY_NAME` | `dashboard.proxy.headerUser` / `.headerEmail` / `.headerGroups` / `.headerDisplayName` |
| `OMNIA_OAUTH_PROVIDER`, `_ISSUER_URL`, `_SCOPES` | `dashboard.oauth.provider`, `.issuerUrl`, `.scopes` |
| `OMNIA_OAUTH_AZURE_TENANT_ID` / `OMNIA_OAUTH_OKTA_DOMAIN` | `dashboard.oauth.azureTenantId` / `.oktaDomain` |
| `OMNIA_OAUTH_CLAIM_USERNAME` / `_EMAIL` / `_DISPLAY_NAME` / `_GROUPS` | `dashboard.oauth.claims.username` / `.email` / `.displayName` / `.groups` |
| `OMNIA_BUILTIN_ALLOW_SIGNUP`, `_VERIFY_EMAIL`, `_MIN_PASSWORD_LENGTH`, `_MAX_FAILED_ATTEMPTS`, `_LOCKOUT_DURATION` | `dashboard.builtin.allowSignup`, `.verifyEmail`, `.minPasswordLength`, `.maxFailedAttempts`, `.lockoutDuration` |
| `OMNIA_SESSION_REDIS_URL` | `dashboard.session.redis`, else `redis.default`. Required with `managementApi.replicaCount` above 1: the chart refuses to render more than one replica without it. Unset, one replica keeps sessions in memory |
| `OMNIA_BUILTIN_ADMIN_USERNAME` / `_EMAIL` | `dashboard.builtin.admin.username` / `.email` |
| `OMNIA_SMTP_HOST`, `_PORT`, `_SECURE`, `_FROM`, `_USER` | `dashboard.builtin.smtp.host`, `.port`, `.secure`, `.from`, `.user`. Set only when `host` is set, on management-api, which sends the email |
| `OMNIA_AUTH_API_KEYS_ENABLED`, `_MAX_PER_USER`, `_DEFAULT_EXPIRATION` | `dashboard.apiKeys.enabled`, `.maxPerUser`, `.defaultExpiration` |

Secret-backed values:

| Value | Secret key | Variable |
|-------|------------|----------|
| `dashboard.auth.existingSessionSecret` | `OMNIA_SESSION_SECRET` | `OMNIA_SESSION_SECRET`. Left empty, the chart generates a secret on first install and keeps it across upgrades. Not set in `anonymous` mode. |
| `dashboard.oauth.existingSecret` | `OMNIA_OAUTH_CLIENT_ID`, `OMNIA_OAUTH_CLIENT_SECRET` | the same names |
| `managementApi.database.existingSecret` | `POSTGRES_CONN` | `POSTGRES_CONN`, on management-api. Required in `builtin` mode: the chart refuses to render without it |
| `dashboard.builtin.existingPostgresSecret` | `OMNIA_BUILTIN_POSTGRES_URL` | `OMNIA_BUILTIN_POSTGRES_URL` |
| `dashboard.builtin.admin.existingSecret` | `OMNIA_BUILTIN_ADMIN_PASSWORD` | `OMNIA_BUILTIN_ADMIN_PASSWORD`, on management-api |
| `dashboard.builtin.smtp.existingSecret` | `OMNIA_SMTP_PASS` | `OMNIA_SMTP_PASS`, on management-api |

Removed and deprecated values:

- `dashboard.builtin.storeType` and `dashboard.builtin.sqlitePath` are removed. They chose the dashboard's own user store, and builtin users now live in management-api's database. Left in a values file, they render and do nothing, and the install notes say so.
- `dashboard.persistence` is deprecated, and nothing mounts it. It was the dashboard's SQLite volume. For one release the chart still renders `<release>-dashboard-data`, when `enabled` is `true` and `existingClaim` is empty, annotated `helm.sh/resource-policy: keep`, so an upgrade from v0.9.0-beta.20, which created it without that annotation, does not delete the old users. Such an install must upgrade through this release rather than skip it, so the claim gets the annotation before a later release stops rendering it. Delete the claim yourself once you will not roll back.
- The session Secret, `<release>-dashboard-session-secret`, is management-api's. It keeps its name, so an upgrade keeps every session.

## OAuth providers

The `OMNIA_OAUTH_PROVIDER` values are `generic`, `google`, `azure`, `okta` and `cognito`. Every provider discovers its configuration via `<issuer>/.well-known/openid-configuration`.

### Generic OIDC

For any OpenID Connect compliant provider.

```bash
OMNIA_OAUTH_PROVIDER=generic
OMNIA_OAUTH_ISSUER_URL=https://auth.example.com
```

**Required:** `OMNIA_OAUTH_ISSUER_URL`

**Scopes:** `openid profile email groups`

### Google

```bash
OMNIA_OAUTH_PROVIDER=google
```

**Issuer:** `https://accounts.google.com`

**Scopes:** `openid profile email`. Username comes from the `email` claim, because Google does not issue `preferred_username`.

Google's ID tokens carry no `groups` claim, for consumer accounts and Google Workspace alike, so group role mapping and workspace `roleBindings` match nothing. Grant access by email with workspace `directGrants`.

**Console:** [Google Cloud Console](https://console.cloud.google.com/apis/credentials)

### Azure AD / Entra ID

```bash
OMNIA_OAUTH_PROVIDER=azure
OMNIA_OAUTH_AZURE_TENANT_ID=your-tenant-id
```

**Issuer:** `https://login.microsoftonline.com/{tenant}/v2.0`

**Scopes:** `openid profile email`

**Console:** [Azure Portal](https://portal.azure.com/#view/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/~/RegisteredApps)

**Group claims:** added under Token Configuration > Add groups claim

### Okta

```bash
OMNIA_OAUTH_PROVIDER=okta
OMNIA_OAUTH_OKTA_DOMAIN=your-domain.okta.com
```

**Issuer:** `https://{domain}`, the Okta org authorization server. For a custom authorization server such as `https://{domain}/oauth2/default`, set `OMNIA_OAUTH_ISSUER_URL`.

**Scopes:** `openid profile email groups`

**Console:** Okta Admin Console > Applications

### Amazon Cognito

```bash
OMNIA_OAUTH_PROVIDER=cognito
OMNIA_OAUTH_ISSUER_URL=https://cognito-idp.<region>.amazonaws.com/<userPoolId>
```

**Required:** `OMNIA_OAUTH_ISSUER_URL`

**Scopes:** `openid profile email`. Cognito rejects an unknown scope, so the preset never requests `groups`.

**Claims:** username from `cognito:username`, groups from `cognito:groups`. Role mapping and workspace `roleBindings` use Cognito group names.

**Logout:** Cognito publishes no `end_session_endpoint`, so logout clears the Omnia session only.

**Console:** Amazon Cognito > User pools > App clients

## API endpoints

### Login

```
GET /api/auth/login?returnTo=/agents
```

Initiates OAuth flow. Query parameter `returnTo` specifies redirect after login. Only a place on `OMNIA_BASE_URL`'s origin is followed; any other value, such as another host, `//host` or `javascript:`, sends the user to `/`.

### Callback

```
GET /api/auth/callback?code=xxx&state=xxx
```

OAuth callback endpoint. Handles authorization code exchange.

### Logout

```
POST /api/auth/logout
```

Clears session. In OAuth mode, may redirect to IdP for SSO logout.

**Response:**
```json
{
  "success": true,
  "redirectUrl": "https://idp.example.com/logout?..."  // Optional
}
```

### Session

```
GET /api/auth/session
```

Returns whether the request is authenticated. It does not return user details.

**Response:**
```json
{ "authenticated": true }
```

### API keys

```
GET /api/settings/api-keys
POST /api/settings/api-keys
DELETE /api/settings/api-keys/:id
```

Manage API keys for the current user.

### Builtin authentication

#### Login

```
POST /api/auth/builtin/login
```

Authenticate with username/email and password.

**Request:**
```json
{
  "username": "user@example.com",
  "password": "secret123"
}
```

**Response:**
```json
{
  "success": true,
  "user": {
    "id": "user-123",
    "username": "jdoe",
    "email": "jdoe@example.com",
    "role": "editor"
  }
}
```

Returns an `error` message string on failure. A locked account also sets `locked: true`; an unverified email also sets `emailNotVerified: true`.

#### Signup

```
POST /api/auth/builtin/signup
```

Register a new user (if `OMNIA_BUILTIN_ALLOW_SIGNUP=true`).

**Request:**
```json
{
  "username": "jdoe",
  "email": "jdoe@example.com",
  "password": "secret123",
  "displayName": "John Doe"
}
```

Returns an `error` message string on failure; a field-specific failure also sets `field` to the offending field name (for example `"email"`).

#### Forgot password

```
POST /api/auth/builtin/forgot-password
```

Request a password reset token.

**Request:**
```json
{
  "email": "jdoe@example.com"
}
```

**Response:** Always returns success (prevents email enumeration).

#### Reset password

```
POST /api/auth/builtin/reset-password
```

Set new password using reset token.

**Request:**
```json
{
  "token": "reset-token-from-email",
  "password": "newpassword123"
}
```

Returns an `error` message string on failure.

#### Verify email

```
POST /api/auth/builtin/verify-email
```

Verify email address using verification token.

**Request:**
```json
{
  "token": "verification-token-from-email"
}
```

Returns an `error` message string on failure.

## Session data structure

The session cookie holds only `{ sid }`. management-api writes the full record server-side under that ID and is the only reader. The session store holds the record, and a separate record for in-flight PKCE state. The store is Redis when `OMNIA_SESSION_REDIS_URL` is set, which every replica shares, and management-api's memory otherwise.

```typescript
interface SessionRecord {
  user: User;
  oauth?: OAuthTokens;
  createdAt: number; // wall-clock ms; diagnostic only
}

interface User {
  id: string;
  username: string;
  email?: string;
  displayName?: string;
  groups: string[];
  role: "admin" | "editor" | "viewer";
  provider: "anonymous" | "proxy" | "oauth" | "builtin";
}

interface OAuthTokens {
  refreshToken?: string;
  idToken?: string;
  expiresAt?: number; // Unix seconds
  provider: "generic" | "google" | "azure" | "okta";
}

// Stored under the IdP state value; consumed once at callback.
interface PkceRecord {
  codeVerifier: string;
  codeChallenge: string;
  state: string;
  returnTo?: string;
  createdAt: number;
}
```

## User roles

### Permissions matrix

The platform role governs **cross-workspace** actions only. Everything inside a
workspace is decided by the caller's role in that workspace, so multi-tenancy
follows workspace boundaries.

**Platform role** (from `OMNIA_AUTH_ROLE_*_GROUPS`):

| Action | Viewer | Editor | Admin |
|--------|--------|--------|-------|
| Manage own API keys | Yes | Yes | Yes |
| View all users' API keys | No | No | Yes |
| View and edit dashboard settings | No | No | Yes |
| Manage users | No | No | Yes |
| List credentials across every workspace | No | No | Yes |
| Manage credentials in a namespace no workspace owns | No | No | Yes |
| Manage access bindings on any workspace | No | No | Yes |

A platform `editor` has no rights beyond a `viewer`: editing happens inside a
workspace, under the workspace role.

**Workspace role** (from the Workspace's `roleBindings` and `directGrants`):

| Action | Viewer | Editor | Owner |
|--------|--------|--------|-------|
| View agents, prompt packs, tools, sessions, logs, metrics | Yes | Yes | Yes |
| View the workspace's providers | Yes | Yes | Yes |
| View the workspace's credentials (metadata only) | No | Yes | Yes |
| Deploy, scale and delete agents | No | Yes | Yes |
| Modify prompt packs and tools | No | Yes | Yes |
| Create, update and delete the workspace's credentials | No | No | Yes |
| Change a provider's credential (`secretRef`) | No | No | Yes |
| Manage workspace members | No | No | Yes |

Credential and provider routes are addressed by workspace
(`/api/workspaces/{name}/secrets`, `/api/workspaces/{name}/providers/{provider}`),
never by namespace, and make their Kubernetes calls as the caller's role in
that workspace. The viewer role holds no access to Secrets, since reading one
is reading its values, so credentials are listed from the editor up; only the
owner role may write Secrets and Providers, so only an owner changes them. A platform role never grants access to a workspace's
data. A platform admin can self-grant a workspace role through
**Settings → Access** like anyone else.

### Role resolution order

1. Check admin groups
2. Check editor groups
3. Default to viewer

First match wins. Example:

```bash
OMNIA_AUTH_ROLE_ADMIN_GROUPS=admins,super-users
OMNIA_AUTH_ROLE_EDITOR_GROUPS=developers,ops
```

User with groups `["developers", "admins"]` gets **admin** role (checked first).

## API key format

API keys are `omnia_sk_` followed by 32 random bytes, base64url-encoded:

```
omnia_sk_[base64url-random]
```

The key ID, user ID, creation timestamp and a bcrypt hash of the key are stored as separate columns in the key store. Authenticating a request bcrypt-compares the presented key against every non-expired key's stored hash; nothing is decoded from the key itself.

## HTTP headers

### Request headers

| Header | Description |
|--------|-------------|
| `Authorization` | Bearer token: `Bearer omnia_sk_...` |
| `X-API-Key` | Alternative API key header |
| `Cookie` | Session cookie (browser requests) |

## Error codes

### OAuth errors

| Error | Description |
|-------|-------------|
| `invalid_state` | CSRF state mismatch |
| `no_code` | No authorization code in callback |
| `callback_failed` | Token exchange failed |
| `access_denied` | User denied consent |
| `invalid_claims` | Missing required claims |
| `config_error` | OAuth misconfiguration |

### API errors

| Status | Error | Source | Description |
|--------|-------|--------|-------------|
| 401 | `unauthenticated` | management-api sign-in gate | No valid session or API key, or a credential that does not check out |
| 401 | `unauthorized` | management-api caller resolution | The caller could not be resolved, for example with the session store unreachable |
| 403 | `Forbidden` | management-api operation guard | Insufficient permissions |

## Security headers

Pages and API answers carry different headers, because different services send them.

Pages come from the dashboard's nginx, which sends these headers on every page and static file. The token pages, `/reset-password` and `/verify-email`, send `Referrer-Policy: no-referrer` instead.

| Header | Value |
|--------|-------|
| `Strict-Transport-Security` | `max-age=63072000; includeSubDomains; preload` |
| `Content-Security-Policy` | `default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' ws: wss:; worker-src 'self' blob:; media-src 'self' blob: data:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'` |
| `X-Frame-Options` | `DENY` |
| `X-Content-Type-Options` | `nosniff` |
| `Referrer-Policy` | `strict-origin-when-cross-origin` |
| `Permissions-Policy` | `camera=(), microphone=(self), geolocation=(self), payment=()` |

`OMNIA_CSP_POLICY`, on the dashboard container, replaces the `Content-Security-Policy` value wholesale. The other page headers have no override.

API answers come from management-api. The chart's HTTPRoute and Ingress send every `/api` path straight to it, and it sets its own headers on every answer:

| Header | Value |
|--------|-------|
| `X-Content-Type-Options` | `nosniff` |
| `X-Frame-Options` | `DENY` |
| `Strict-Transport-Security` | `max-age=63072000; includeSubDomains; preload`, only when `OMNIA_BASE_URL` is HTTPS |

nginx's own proxy applies only to `/api` traffic that reaches the dashboard Service, for example behind a proxy of your own that sends everything there. Those answers carry nginx's page headers instead of management-api's.

Session cookies are `HttpOnly; SameSite=Lax; Path=/`, plus `Secure` in production.

## Callback URLs

management-api uses these URLs for the IdP redirect:

| Provider | Redirect URI |
|----------|--------------|
| All | `https://your-domain/api/auth/callback` |

Post-logout redirect:

| Provider | Post-Logout URI |
|----------|-----------------|
| All | `https://your-domain/login` |