Authentication Configuration
API keys, JWT/OIDC, reverse proxy auth, and CEL-based RBAC policies
This page is the configuration reference for authentication settings in hadrian.toml. For conceptual overviews and guides, see:
The [auth] section configures authentication and authorization for both API requests and the web UI.
Authentication Architecture
Hadrian separates authentication into two contexts:
| Context | Section | Purpose |
|---|---|---|
| Gateway | [auth.gateway] | Authenticate programmatic gateway API requests |
| Admin | [auth.admin] | Authenticate browser sessions for the admin UI |
You can configure different authentication methods for each context. For example, use API keys for programmatic access and OIDC for the admin UI.
Gateway Authentication
Configure how gateway API requests (/v1/* endpoints) are authenticated.
No Authentication
Allows any request without credentials. Only suitable for local development.
[auth.gateway]
type = "none"Never use type = "none" in production. All requests will be unauthenticated and usage cannot be
tracked or billed.
API Key Authentication
Validates API keys stored in the database. Keys are created via the Admin API or UI.
[auth.gateway]
type = "api_key"
header_name = "X-API-Key"
key_prefix = "gw_"
generation_prefix = "gw_live_"
hash_algorithm = "sha256"
cache_ttl_secs = 60| Setting | Type | Default | Description |
|---|---|---|---|
header_name | string | X-API-Key | Header containing the API key. Also accepts Authorization: Bearer <key>. |
key_prefix | string | gw_ | Prefix for validating keys. Keys not starting with this prefix are rejected. |
generation_prefix | string | gw_live_ | Prefix for generating new keys. Distinguishes live keys from test keys. |
hash_algorithm | string | sha256 | Algorithm for hashing stored keys. Options: sha256, argon2. |
cache_ttl_secs | integer | 60 | Cache validated keys for this duration. Set to 0 for no caching. |
Hash Algorithms
| Algorithm | Speed | Use Case |
|---|---|---|
sha256 | Fast | High-entropy keys (recommended). Minimal latency impact. |
argon2 | Slow | Low-entropy keys or extra security. Adds ~50ms per uncached lookup. |
Request Format
API keys can be sent in two formats:
# X-API-Key header
curl -H "X-API-Key: gw_live_abc123..." https://gateway.example.com/v1/chat/completions
# Authorization header (OpenAI-compatible)
curl -H "Authorization: Bearer gw_live_abc123..." https://gateway.example.com/v1/chat/completionsAPI Key Scoping
API keys can be restricted with permission scopes, model restrictions, IP allowlists, and per-key rate limits.
Permission Scopes
Control which API endpoints a key can access:
| Scope | Endpoints |
|---|---|
chat | /v1/chat/completions, /v1/responses |
completions | /v1/completions (legacy) |
embeddings | /v1/embeddings |
images | /v1/images/* |
audio | /v1/audio/* |
files | /v1/files/*, /v1/vector_stores/* |
models | /v1/models |
admin | /admin/* |
Example creating a key limited to chat and embeddings:
curl -X POST https://gateway.example.com/admin/v1/api-keys \
-H "Content-Type: application/json" \
-d '{
"name": "ML Pipeline Key",
"owner": {"type": "organization", "org_id": "..."},
"scopes": ["chat", "embeddings"]
}'When scopes is null or omitted, the key has full access to all endpoints.
Model Restrictions
Limit which models a key can use with wildcard patterns:
{
"allowed_models": ["gpt-4*", "claude-3-opus"]
}Pattern rules:
- Exact match:
"gpt-4"matches onlygpt-4 - Trailing wildcard:
"gpt-4*"matchesgpt-4,gpt-4o,gpt-4-turbo - No bare
*: Usenullfor unrestricted model access
IP Allowlists
Restrict key usage to specific IP addresses or CIDR ranges:
{
"ip_allowlist": ["10.0.0.0/8", "192.168.1.100", "2001:db8::/32"]
}Supports both IPv4 and IPv6 addresses and CIDR notation.
Per-Key Rate Limits
Override global rate limits for specific keys:
{
"rate_limit_rpm": 100,
"rate_limit_tpm": 50000
}| Setting | Type | Description |
|---|---|---|
rate_limit_rpm | integer | Requests per minute |
rate_limit_tpm | integer | Tokens per minute |
By default, per-key limits cannot exceed global limits. Set allow_per_key_above_global = true in [limits.rate_limits] to allow per-key limits higher than global defaults.
Key Rotation
Rotate keys with a grace period during which both old and new keys work:
curl -X POST https://gateway.example.com/admin/v1/api-keys/{key_id}/rotate \
-H "Content-Type: application/json" \
-d '{"grace_period_seconds": 86400}'| Parameter | Type | Default | Max | Description |
|---|---|---|---|---|
grace_period_seconds | integer | 86400 | 604800 | Duration both keys remain valid (24h default, 7 days max) |
The response includes the new API key (store securely). The old key remains valid until the grace period expires, then is automatically treated as revoked.
Key rotation is useful for zero-downtime credential updates. Update your applications to use the new key during the grace period, then the old key automatically becomes inactive.
JWT Authentication
Validates JWTs signed by an identity provider. Tokens are verified against a JWKS endpoint.
[auth.gateway]
type = "jwt"
issuer = "https://auth.example.com"
audience = "hadrian"
jwks_url = "https://auth.example.com/.well-known/jwks.json"
jwks_refresh_secs = 3600
identity_claim = "sub"
org_claim = "org_id"
additional_claims = ["email", "name"]
allowed_algorithms = ["RS256", "ES256"]| Setting | Type | Default | Description |
|---|---|---|---|
issuer | string | — | Expected iss claim. Tokens from other issuers are rejected. |
audience | string or array | — | Expected aud claim. Can be a single value or list. |
jwks_url | string | — | URL to fetch public keys for signature verification. |
jwks_refresh_secs | integer | 3600 | How often to refresh the JWKS (seconds). |
identity_claim | string | sub | Claim to use as the user's identity ID. |
org_claim | string | None | Claim containing the user's organization ID (optional). |
additional_claims | string[] | [] | Extra claims to extract and include in the request context. |
allow_expired | boolean | false | Accept expired tokens. For testing only. |
allowed_algorithms | string[] | ["RS256", "RS384", "RS512", ...] | JWT signing algorithms to accept. |
Allowed Algorithms
Always explicitly specify allowed_algorithms to prevent algorithm confusion attacks. Avoid HMAC
algorithms (HS256, HS384, HS512) unless you control both signing and verification.
| Algorithm | Type | Security Level | Notes |
|---|---|---|---|
RS256 | RSA | Recommended | Most widely supported |
RS384 | RSA | Recommended | Stronger hash |
RS512 | RSA | Recommended | Strongest RSA option |
ES256 | ECDSA | Recommended | Compact signatures, P-256 curve |
ES384 | ECDSA | Recommended | P-384 curve |
PS256 | RSA-PSS | Recommended | Probabilistic signatures |
PS384 | RSA-PSS | Recommended | Probabilistic signatures |
PS512 | RSA-PSS | Recommended | Probabilistic signatures |
EdDSA | EdDSA | Recommended | Ed25519, modern and fast |
HS256 | HMAC | Use with care | Symmetric, requires shared secret |
HS384 | HMAC | Use with care | Symmetric, requires shared secret |
HS512 | HMAC | Use with care | Symmetric, requires shared secret |
Multi-Auth (API Key + JWT)
Support both API keys and JWTs. The gateway uses format-based detection to determine which authentication method to use:
- X-API-Key header: Always validated as an API key
- Authorization: Bearer header: Uses format-based detection:
- Tokens starting with the configured API key prefix (default:
gw_) are validated as API keys - All other tokens are validated as JWTs
- Tokens starting with the configured API key prefix (default:
Providing both X-API-Key and Authorization headers simultaneously results in a 400 error
(ambiguous credentials). Choose one authentication method per request.
[auth.gateway]
type = "multi"
[auth.gateway.api_key]
header_name = "X-API-Key"
key_prefix = "gw_"
cache_ttl_secs = 300
[auth.gateway.jwt]
issuer = "https://auth.example.com"
audience = "hadrian"
jwks_url = "https://auth.example.com/.well-known/jwks.json"Request Examples:
# API key in X-API-Key header
curl -H "X-API-Key: gw_live_abc123..." https://gateway.example.com/v1/chat/completions
# API key in Authorization: Bearer header (format-based detection)
curl -H "Authorization: Bearer gw_live_abc123..." https://gateway.example.com/v1/chat/completions
# JWT in Authorization: Bearer header
curl -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..." https://gateway.example.com/v1/chat/completionsAdmin Authentication
Configure how browser sessions for the admin UI are authenticated.
No Authentication
Allows anonymous access to the UI. Suitable for local development or internal tools.
[auth.admin]
type = "none"Reverse Proxy Authentication
Trust identity headers set by an authenticating reverse proxy. Works with:
- Cloudflare Access
- oauth2-proxy
- Tailscale
- Authelia / Authentik
- Keycloak Gatekeeper
- Pomerium
[auth.admin]
type = "proxy_auth"
identity_header = "X-Forwarded-User"
email_header = "X-Forwarded-Email"
name_header = "X-Forwarded-Name"
groups_header = "X-Forwarded-Groups"
require_identity = true| Setting | Type | Default | Description |
|---|---|---|---|
identity_header | string | — | Header containing the authenticated user's ID (required). |
email_header | string | None | Header containing the user's email. |
name_header | string | None | Header containing the user's display name. |
groups_header | string | None | Header containing groups/roles (comma-separated or JSON array). |
require_identity | boolean | true | Require identity headers on all requests. If false, anonymous access allowed. |
Critical Security: Configure [server.trusted_proxies] to prevent header spoofing. Without
this, attackers can forge identity headers and impersonate any user.
Cloudflare Access Example
[server.trusted_proxies]
cidrs = ["173.245.48.0/20", "103.21.244.0/22", "103.22.200.0/22", "103.31.4.0/22"]
[auth.admin]
type = "proxy_auth"
identity_header = "Cf-Access-Authenticated-User-Email"
email_header = "Cf-Access-Authenticated-User-Email"oauth2-proxy Example
[server.trusted_proxies]
cidrs = ["10.0.0.0/8"]
[auth.admin]
type = "proxy_auth"
identity_header = "X-Forwarded-User"
email_header = "X-Forwarded-Email"
groups_header = "X-Forwarded-Groups"JWT Assertion (Optional)
For additional security, validate a signed JWT from the proxy:
[auth.admin]
type = "proxy_auth"
identity_header = "X-Forwarded-User"
[auth.admin.jwt_assertion]
header = "Cf-Access-Jwt-Assertion"
jwks_url = "https://your-team.cloudflareaccess.com/cdn-cgi/access/certs"
issuer = "https://your-team.cloudflareaccess.com"
audience = "your-audience-id"Session Authentication
Session-based authentication using per-organization SSO. Each organization configures their own identity provider (OIDC or SAML) via the Admin UI, and users authenticate by discovering their organization via email domain.
[auth.admin]
type = "session"
cookie_name = "__gw_session"
duration_secs = 604800 # 7 days
secure = true
same_site = "lax"
secret = "${SESSION_SECRET}"| Setting | Type | Default | Description |
|---|---|---|---|
cookie_name | string | __gw_session | Session cookie name. |
duration_secs | integer | 604800 (7 days) | Session duration. |
secure | boolean | true | HTTPS-only cookies. Set to false for local development over HTTP. |
same_site | string | lax | SameSite attribute. Options: strict, lax, none. |
secret | string | Auto-generated | Secret for signing session cookies. Sessions are lost on restart if not set. |
With session auth, SSO is configured per-organization via the Admin UI. Each organization can have their own OIDC or SAML identity provider. Users authenticate by entering their email, which discovers their organization's SSO configuration. See the SSO Admin Guide.
Login Flow
- User navigates to
/auth/login?org=<org-slug>or uses email discovery via/auth/discover - Gateway redirects to the organization's configured IdP
- After IdP authentication, callback creates a session cookie
- Subsequent requests are authenticated via the session cookie
Per-Organization SSO
SSO configurations are stored in the database and managed via the Admin API or UI:
POST /admin/v1/organizations/{org_slug}/sso-configs- Create SSO configurationGET /admin/v1/organizations/{org_slug}/sso-configs- Get SSO configurationPUT /admin/v1/organizations/{org_slug}/sso-configs- Update SSO configurationDELETE /admin/v1/organizations/{org_slug}/sso-configs- Delete SSO configuration
Each SSO configuration includes:
- Provider type (OIDC or SAML)
- Issuer URL and discovery settings
- Client credentials (stored encrypted)
- Allowed email domains
- JIT provisioning settings
Single-Org Membership
Users can only belong to one organization. When a user authenticates via an organization's SSO, they are associated with that organization. If a user tries to authenticate via a different organization's SSO, they will receive an error.
To move a user to a different organization, first remove them from their current organization.
Session Configuration
[auth.admin.session]
cookie_name = "__gw_session"
duration_secs = 604800 # 7 days
secure = true
same_site = "lax"
secret = "${SESSION_SECRET}"| Setting | Type | Default | Description |
|---|---|---|---|
cookie_name | string | __gw_session | Session cookie name. |
duration_secs | integer | 604800 (7 days) | Session duration. |
secure | boolean | true | HTTPS-only cookies. Set to false for local development over HTTP. |
same_site | string | lax | SameSite attribute. Options: strict, lax, none. |
secret | string | Auto-generated | Secret for signing session cookies. Sessions are lost on restart if not set. |
If secret is not configured, a random key is generated on startup. This means sessions won't
survive gateway restarts and won't work in multi-node deployments.
Auth States in Memory: During login flows, authentication state (PKCE verifiers, nonces, return URLs) is stored in memory by default. If the gateway restarts during an active login flow, users will see a "Session not found" error. For production deployments with high availability requirements, configure Redis as the session backend to persist auth states across restarts and share them across nodes.
JIT (Just-in-Time) Provisioning
Automatically create users and add them to organizations when they first authenticate via OIDC. This eliminates the need to pre-provision users before they can access the system.
[auth.admin.provisioning]
enabled = true
create_users = true
organization_id = "acme-corp" # Required: org slug or UUID
default_team_id = "engineering" # Optional: team slug or UUID
default_org_role = "member"
default_team_role = "member"
sync_attributes_on_login = true
# sync_memberships_on_login = true
# allowed_email_domains = ["example.com", "acme.org"]| Setting | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | Enable JIT provisioning. |
create_users | boolean | false | Create users in the database on first login. |
organization_id | string | None | Organization slug or UUID to provision users into. |
default_team_id | string | None | Team slug or UUID to add users to (requires organization_id). |
default_org_role | string | member | Role assigned to users when added to organizations. |
default_team_role | string | member | Role assigned to users when added to teams. |
allowed_email_domains | string[] | [] | Restrict provisioning to specific email domains. Empty = allow all. |
sync_attributes_on_login | boolean | false | Update user name/email from IdP on subsequent logins. |
sync_memberships_on_login | boolean | false | Remove memberships not in current provisioning config. See warning below. |
The organization_id can be either a UUID or a slug. The organization must exist in the database
before users can be provisioned into it. Create it via the Admin API or UI first.
Membership Sync Warning: When sync_memberships_on_login is enabled, users will lose access
to organizations and teams not configured in the provisioning settings. All membership changes are
logged in the audit log.
Example: Basic Setup
Provision all SSO users into a single organization:
[auth.admin.provisioning]
enabled = true
create_users = true
organization_id = "acme-corp"
default_org_role = "member"
sync_attributes_on_login = trueExample: With Default Team
Provision users into an organization and a default team:
[auth.admin.provisioning]
enabled = true
create_users = true
organization_id = "acme-corp"
default_team_id = "new-employees"
default_org_role = "member"
default_team_role = "member"Example: Restrict by Email Domain
Only provision users from specific email domains:
[auth.admin.provisioning]
enabled = true
create_users = true
organization_id = "acme-corp"
allowed_email_domains = ["acme.com", "acme.org"]SSO Group Mappings
Map IdP groups to Hadrian teams for automatic team assignment during JIT provisioning.
SSO group mappings are configured via the Admin UI, not in hadrian.toml. See the SSO Admin
Guide for setup instructions.
Prerequisite: SSO group mappings require groups_claim to be configured in your OIDC settings. The IdP must include group membership in the ID token.
Each mapping specifies:
| Field | Description |
|---|---|
| IdP Group | The exact group name from the identity provider (case-sensitive) |
| Team | The Hadrian team to add the user to |
| Role | Role within the team (member, admin, etc.). Uses default_team_role if not specified. |
| Priority | Precedence when multiple mappings target the same team (higher wins) |
Priority Resolution
When a user belongs to multiple IdP groups that map to the same team, the mapping with the highest priority value determines the role:
- Higher
priorityvalue wins - If priorities are equal, alphabetically earlier group name wins
- If still tied, earlier creation time wins
Each team appears at most once in the resolved memberships.
Example: Role Escalation by Group
| IdP Group | Team | Role | Priority |
|---|---|---|---|
Engineers | platform | member | 0 |
SeniorEngineers | platform | lead | 10 |
PlatformAdmins | platform | admin | 20 |
A user in both Engineers and SeniorEngineers groups receives the lead role (priority 10 beats priority 0). A user in all three groups receives the admin role (priority 20 wins).
Example: Multiple Team Membership
| IdP Group | Team | Role | Priority |
|---|---|---|---|
Engineering | backend | member | 0 |
Engineering | frontend | member | 0 |
Engineering | platform | member | 0 |
A user in the Engineering group is added to all three teams with member role.
Mappings without a team target org-level roles only and are skipped during team assignment. Use these for granting organization-wide permissions without team membership.
Admin API
Group mappings can also be managed programmatically via the Admin API:
GET /admin/v1/organizations/{org_slug}/sso-group-mappings- List mappingsPOST /admin/v1/organizations/{org_slug}/sso-group-mappings- Create mappingPATCH /admin/v1/organizations/{org_slug}/sso-group-mappings/{id}- Update mappingDELETE /admin/v1/organizations/{org_slug}/sso-group-mappings/{id}- Delete mappingPOST /admin/v1/organizations/{org_slug}/sso-group-mappings/test- Test mappings against IdP groups
See the API Reference for full documentation.
Bulk Import/Export
For large mapping sets, use the import/export feature in the Admin UI:
- Export: Download mappings as CSV or JSON for backup or migration
- Import: Upload a JSON file with mappings and choose conflict resolution:
skip- Skip mappings that already existoverwrite- Update existing mappings with imported valueserror- Fail if any mapping already exists
SCIM Provisioning
SCIM (System for Cross-domain Identity Management) provides real-time user provisioning and deprovisioning from your identity provider. Unlike JIT provisioning which only triggers on login, SCIM syncs changes immediately.
SCIM is configured per-organization via the Admin UI, not in hadrian.toml. See the SCIM
Provisioning Guide for setup instructions.
JIT vs SCIM Provisioning
| Capability | JIT | SCIM |
|---|---|---|
| User creation | On first login | Immediate |
| User deactivation | On next login attempt | Immediate |
| API key revocation | Never (manual) | Immediate (configurable) |
| Group membership sync | On login | Real-time |
| Compliance (SOC 2, HIPAA) | Partial | Full |
When to use each:
- JIT only: Simple deployments where immediate deprovisioning isn't critical
- SCIM only: Enterprise deployments requiring real-time access control
- JIT + SCIM: SCIM for provisioning, JIT for fallback and attribute sync on login
Admin API
SCIM configuration is managed via the Admin API:
GET /admin/v1/organizations/{org_slug}/scim-configs- Get SCIM configurationPOST /admin/v1/organizations/{org_slug}/scim-configs- Create SCIM configurationPUT /admin/v1/organizations/{org_slug}/scim-configs- Update SCIM configurationDELETE /admin/v1/organizations/{org_slug}/scim-configs- Delete SCIM configurationPOST /admin/v1/organizations/{org_slug}/scim-configs/rotate-token- Rotate bearer token
RBAC Configuration
Role-Based Access Control uses CEL (Common Expression Language) policies for fine-grained authorization.
[auth.rbac]
enabled = true
default_effect = "deny"
role_claim = "roles"
org_claim = "groups"
team_claim = "groups"
project_claim = "project_ids"
[auth.rbac.role_mapping]
"Administrator" = "admin"
"Developer" = "user"
[auth.rbac.audit]
log_allowed = false
log_denied = true| Setting | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | Enable RBAC policy evaluation. |
default_effect | string | deny | Default when no policy matches. Options: allow, deny. |
role_claim | string | roles | JWT claim containing user roles. |
org_claim | string | None | JWT claim containing organization IDs. |
team_claim | string | None | JWT claim containing team IDs. |
project_claim | string | None | JWT claim containing project IDs. |
role_mapping | map | {} | Map IdP role names to internal role names. |
CEL Policy Structure
Policies are defined as an array of rules, each with a CEL condition:
[[auth.rbac.policies]]
name = "super-admin-full-access"
description = "Super admins have unrestricted access"
resource = "*"
action = "*"
condition = "'super_admin' in subject.roles"
effect = "allow"
priority = 100| Field | Type | Default | Description |
|---|---|---|---|
name | string | — | Unique policy identifier (required). |
description | string | None | Human-readable description. |
resource | string | * | Resource type this policy applies to. |
action | string | * | Action this policy applies to. |
condition | string | — | CEL expression that must evaluate to true (required). |
effect | string | — | allow or deny (required). |
priority | integer | 0 | Evaluation order. Higher = evaluated first. Ties: deny before allow. |
CEL Variables
The following variables are available in CEL expressions:
Subject Variables
| Variable | Type | Description |
|---|---|---|
subject.user_id | string | Internal user ID |
subject.external_id | string | IdP user ID (from identity claim) |
subject.email | string | User's email address |
subject.roles | string[] | List of role names |
subject.org_ids | string[] | Organization IDs the user belongs to |
subject.team_ids | string[] | Team IDs the user belongs to |
subject.project_ids | string[] | Project IDs the user belongs to |
Context Variables (All Endpoints)
| Variable | Type | Description |
|---|---|---|
context.resource_type | string | Resource being accessed |
context.action | string | Action being performed |
context.resource_id | string | Specific resource ID |
context.org_id | string | Target organization ID |
context.team_id | string | Target team ID |
context.project_id | string | Target project ID |
API Endpoint Variables
These variables are available when Gateway RBAC is enabled for /v1/* endpoints:
| Variable | Type | Description |
|---|---|---|
context.model | string | Model being requested (e.g., "gpt-4o", "claude-3-opus") |
context.request.max_tokens | int | Maximum tokens requested |
context.request.messages_count | int | Number of messages in conversation |
context.request.has_tools | bool | Whether request includes tools/functions |
context.request.has_file_search | bool | Whether request includes file_search tool (RAG) |
context.request.stream | bool | Whether streaming is requested |
context.request.reasoning_effort | string | Reasoning effort: "none", "minimal", "low", "medium", "high" |
context.request.response_format | string | Output format: "text", "json_object", "json_schema" |
context.request.temperature | float | Sampling temperature (0.0-2.0) |
context.request.has_images | bool | Whether request contains image content (multimodal) |
context.request.image_count | int | Number of images to generate (image endpoints) |
context.request.image_size | string | Image size: "256x256", "512x512", "1024x1024", etc. |
context.request.image_quality | string | Image quality: "standard", "hd", "low", "medium", "high", "auto" |
context.request.character_count | int | Text length in characters (TTS endpoints) |
context.request.voice | string | TTS voice: "alloy", "echo", "fable", "onyx", "nova", "shimmer" |
context.request.language | string | ISO-639-1 language code (audio transcription) |
context.now.hour | int | Current hour (0-23) |
context.now.day_of_week | int | Day of week (1=Monday, 7=Sunday) |
context.now.timestamp | int | Unix timestamp |
Policy Examples
Deny Self-Deletion
[[auth.rbac.policies]]
name = "deny-self-delete"
description = "Users cannot delete themselves"
resource = "user"
action = "delete"
condition = "subject.user_id == context.resource_id"
effect = "deny"
priority = 200 # High priority, evaluated firstOrg Admin Access
[[auth.rbac.policies]]
name = "org-admin-manage-org"
description = "Org admins can manage their own organization"
resource = "organization"
action = "*"
condition = "'org_admin' in subject.roles && context.org_id in subject.org_ids"
effect = "allow"
priority = 80Cross-Org Isolation
[[auth.rbac.policies]]
name = "org-isolation"
description = "Users can only access resources in their organizations"
resource = "*"
action = "*"
condition = "context.org_id == null || context.org_id in subject.org_ids"
effect = "allow"
priority = 10User Self-Service
[[auth.rbac.policies]]
name = "user-manage-own-api-keys"
description = "Users can manage their own API keys"
resource = "api_key"
action = "*"
condition = "context.owner_id == subject.user_id"
effect = "allow"
priority = 40Gateway Authorization
Gateway RBAC extends CEL policies to /v1/* endpoints, enabling fine-grained control over model access, token limits, feature gating, and more.
Configuration
[auth.rbac.gateway]
enabled = true
default_effect = "allow"| Setting | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | Enable policy evaluation for API endpoints. |
default_effect | string | allow | Default when no policy matches. Options: allow (fail-open), deny. |
Gateway RBAC defaults to enabled = false and default_effect = "allow" for backwards
compatibility. Existing deployments continue to work without changes. Enable explicitly to enforce
policies.
Authentication Requirements
Gateway RBAC policies require identity information (roles, org membership) from JWT tokens. Configure multi-auth to support both API keys and JWTs:
[auth.gateway]
type = "multi"
[auth.gateway.api_key]
key_prefix = "gw_"
[auth.gateway.jwt]
issuer = "https://auth.example.com"
audience = "hadrian-api"
jwks_url = "https://auth.example.com/.well-known/jwks.json"API key authentication alone does not provide role information. Use JWT authentication or
multi-auth for Gateway RBAC policies that check subject.roles.
Policy Examples
Model Access Control
Restrict expensive models to premium users:
[[auth.rbac.policies]]
name = "restrict-premium-models"
description = "Premium models require premium role"
resource = "model"
action = "use"
condition = """
context.model != null &&
context.model.startsWith('gpt-4') &&
!('premium' in subject.roles)
"""
effect = "deny"
priority = 90Allow admins unrestricted model access:
[[auth.rbac.policies]]
name = "admin-all-models"
description = "Admins can use any model"
resource = "model"
action = "use"
condition = "'admin' in subject.roles"
effect = "allow"
priority = 100Token Limits
Enforce token limits by tier:
[[auth.rbac.policies]]
name = "basic-token-limit"
description = "Basic users limited to 1000 tokens"
resource = "model"
action = "use"
condition = """
context.request != null &&
context.request.max_tokens > 1000 &&
!('premium' in subject.roles)
"""
effect = "deny"
priority = 85Feature Gating
Require specific roles for advanced features:
# Function calling requires tools_enabled role
[[auth.rbac.policies]]
name = "tools-feature-gate"
description = "Function calling requires tools_enabled role"
resource = "model"
action = "use"
condition = """
context.request != null &&
context.request.has_tools &&
!('tools_enabled' in subject.roles)
"""
effect = "deny"
priority = 85
# RAG/file search requires rag_enabled role
[[auth.rbac.policies]]
name = "rag-feature-gate"
description = "File search requires rag_enabled role"
resource = "model"
action = "use"
condition = """
context.request != null &&
context.request.has_file_search &&
!('rag_enabled' in subject.roles)
"""
effect = "deny"
priority = 85Reasoning/Extended Thinking Control
Restrict high-effort reasoning to premium users:
[[auth.rbac.policies]]
name = "reasoning-premium"
description = "High reasoning effort requires premium role"
resource = "model"
action = "use"
condition = """
context.request != null &&
context.request.reasoning_effort == 'high' &&
!('premium' in subject.roles)
"""
effect = "deny"
priority = 85Vision/Multimodal Control
Restrict image input to users with vision access:
[[auth.rbac.policies]]
name = "vision-feature-gate"
description = "Image input requires vision role"
resource = "model"
action = "use"
condition = """
context.request != null &&
context.request.has_images &&
!('vision' in subject.roles)
"""
effect = "deny"
priority = 85Image Generation Limits
Control image generation by count and quality:
# Limit image count for free tier
[[auth.rbac.policies]]
name = "image-count-limit"
description = "Free tier limited to 2 images per request"
resource = "model"
action = "use"
condition = """
context.request != null &&
context.request.image_count > 2 &&
!('premium' in subject.roles)
"""
effect = "deny"
priority = 85
# Restrict HD quality to premium
[[auth.rbac.policies]]
name = "hd-images-premium"
description = "HD image quality requires premium role"
resource = "model"
action = "use"
condition = """
context.request != null &&
context.request.image_quality == 'hd' &&
!('premium' in subject.roles)
"""
effect = "deny"
priority = 85Audio TTS Limits
Limit text-to-speech character count:
[[auth.rbac.policies]]
name = "tts-character-limit"
description = "TTS limited to 1000 characters for basic tier"
resource = "model"
action = "use"
condition = """
context.request != null &&
context.request.character_count > 1000 &&
!('tts_extended' in subject.roles)
"""
effect = "deny"
priority = 85Time-Based Access
Restrict API access to business hours:
[[auth.rbac.policies]]
name = "business-hours-only"
description = "API access restricted to business hours for non-admins"
resource = "model"
action = "use"
condition = """
!('admin' in subject.roles) &&
(context.now.hour < 9 || context.now.hour > 17)
"""
effect = "deny"
priority = 80Bootstrap Configuration
Bootstrap mode enables initial setup when no users exist in the database. This solves the chicken-and-egg problem of needing admin access to configure SSO before any users can authenticate.
Bootstrap API Key
For automated deployments and SSO setup, use a pre-shared API key:
[auth.bootstrap]
# Pre-shared API key for initial setup (only works when no users exist)
api_key = "${HADRIAN_BOOTSTRAP_KEY}"
# Domains automatically verified when SSO config is created (skips DNS verification)
auto_verify_domains = ["acme.com", "acme.io"]| Setting | Type | Description |
|---|---|---|
api_key | string | Pre-shared key for admin access before first user exists |
auto_verify_domains | string[] | Email domains auto-verified when SSO config is created |
The bootstrap API key:
- Only works when the database has no users (organizations can exist)
- Uses the reserved
_system_bootstraprole (cannot be assigned by IdPs) - Automatically becomes inactive after the first user is provisioned via IdP login
For SAML or OIDC initial setup, see the SAML Admin Guide for a complete walkthrough using the bootstrap API key.
Identity-Based Bootstrap
For scenarios where users will authenticate via IdP before accessing the Admin UI:
[auth.bootstrap]
admin_identities = ["alice@example.com", "bob@example.com"]
[auth.bootstrap.initial_org]
slug = "acme-corp"
name = "Acme Corporation"
admin_identities = ["alice@example.com"]| Setting | Type | Description |
|---|---|---|
admin_identities | string[] | External IDs granted system admin role |
initial_org.slug | string | URL-safe organization identifier |
initial_org.name | string | Display name for the organization |
initial_org.admin_identities | string[] | External IDs added as organization admins |
Identity-based bootstrap runs only once on first startup with an empty database. Existing users and organizations are not modified.
Emergency Access Configuration
Emergency access provides break-glass admin access when SSO is unavailable. Unlike bootstrap mode, emergency access remains available indefinitely (when enabled) and is designed for disaster recovery scenarios.
[auth.emergency]
enabled = true
allowed_ips = ["10.0.0.0/8"] # Optional: restrict to admin network
[[auth.emergency.accounts]]
id = "emergency-admin-1"
name = "Primary Emergency Admin"
key = "${EMERGENCY_KEY_1}"
email = "admin@company.com"
roles = ["_emergency_admin", "super_admin"]
[auth.emergency.rate_limit]
max_attempts = 5
window_secs = 900
lockout_secs = 3600| Setting | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | Enable emergency access |
allowed_ips | string[] | [] | Global IP allowlist (CIDR notation) |
accounts[].id | string | - | Unique identifier for audit logs |
accounts[].name | string | - | Human-readable account name |
accounts[].key | string | - | Emergency access key (secret) |
accounts[].email | string | - | Email for audit logging |
accounts[].roles | string[] | [] | Roles granted on authentication |
accounts[].allowed_ips | string[] | [] | Per-account IP restrictions (CIDR) |
rate_limit.max_attempts | u32 | 5 | Failed attempts before lockout |
rate_limit.window_secs | u64 | 900 | Time window for counting attempts (15 min) |
rate_limit.lockout_secs | u64 | 3600 | Lockout duration after max attempts (1 hour) |
Use emergency keys via the X-Emergency-Key header or Authorization: EmergencyKey <key>:
curl -H "X-Emergency-Key: $EMERGENCY_KEY" https://gateway.example.com/admin/v1/organizationsFor detailed usage instructions and the IdP outage runbook, see Emergency Access.
Complete Examples
Development (No Auth)
[auth.gateway]
type = "none"
# No UI auth config = anonymous accessProduction API Keys Only
[auth.gateway]
type = "api_key"
header_name = "X-API-Key"
key_prefix = "gw_"
cache_ttl_secs = 300Keycloak OIDC with JIT Provisioning
[auth.admin]
type = "oidc"
issuer = "https://keycloak.example.com/realms/hadrian"
client_id = "hadrian"
client_secret = "${OIDC_CLIENT_SECRET}"
redirect_uri = "https://gateway.example.com/auth/callback"
identity_claim = "preferred_username"
groups_claim = "groups" # Required for SSO group mappings
[auth.admin.session]
secure = true
secret = "${SESSION_SECRET}"
# JIT provisioning - auto-create users and add to organization
[auth.admin.provisioning]
enabled = true
create_users = true
organization_id = "acme-corp"
default_org_role = "member"
sync_attributes_on_login = true
[auth.gateway]
type = "api_key"
key_prefix = "gw_"
cache_ttl_secs = 300
[auth.rbac]
enabled = true
default_effect = "deny"
role_claim = "roles"
org_claim = "groups"
[[auth.rbac.policies]]
name = "super-admin"
resource = "*"
action = "*"
condition = "'super_admin' in subject.roles"
effect = "allow"
priority = 100
[[auth.rbac.policies]]
name = "org-member-access"
resource = "*"
action = "*"
condition = "context.org_id in subject.org_ids"
effect = "allow"
priority = 50After deploying this configuration, use the Admin UI to set up SSO group mappings that map Keycloak groups to Hadrian teams.
Cloudflare Access + API Keys
[server.trusted_proxies]
cidrs = ["173.245.48.0/20", "103.21.244.0/22", "103.22.200.0/22", "103.31.4.0/22"]
[auth.admin]
type = "proxy_auth"
identity_header = "Cf-Access-Authenticated-User-Email"
email_header = "Cf-Access-Authenticated-User-Email"
[auth.admin.jwt_assertion]
header = "Cf-Access-Jwt-Assertion"
jwks_url = "https://your-team.cloudflareaccess.com/cdn-cgi/access/certs"
issuer = "https://your-team.cloudflareaccess.com"
audience = "your-app-audience-tag"
[auth.gateway]
type = "api_key"
key_prefix = "gw_"Multi-Tenant with Full RBAC
[auth.admin]
type = "oidc"
issuer = "https://auth.example.com"
client_id = "hadrian"
client_secret = "${OIDC_CLIENT_SECRET}"
redirect_uri = "https://gateway.example.com/auth/callback"
groups_claim = "groups"
[auth.gateway]
type = "multi"
[auth.gateway.api_key]
key_prefix = "gw_"
cache_ttl_secs = 300
[auth.gateway.jwt]
issuer = "https://auth.example.com"
audience = "hadrian-api"
jwks_url = "https://auth.example.com/.well-known/jwks.json"
[auth.rbac]
enabled = true
default_effect = "deny"
role_claim = "roles"
org_claim = "groups"
team_claim = "groups"
[auth.rbac.audit]
log_allowed = false
log_denied = true
# Deny policies (high priority)
[[auth.rbac.policies]]
name = "deny-self-delete"
resource = "user"
action = "delete"
condition = "subject.user_id == context.resource_id"
effect = "deny"
priority = 200
# Super admin (priority 100)
[[auth.rbac.policies]]
name = "super-admin"
resource = "*"
action = "*"
condition = "'super_admin' in subject.roles"
effect = "allow"
priority = 100
# Org admin (priority 80)
[[auth.rbac.policies]]
name = "org-admin"
resource = "*"
action = "*"
condition = "'org_admin' in subject.roles && context.org_id in subject.org_ids"
effect = "allow"
priority = 80
# Team admin (priority 60)
[[auth.rbac.policies]]
name = "team-admin"
resource = "*"
action = "*"
condition = "'team_admin' in subject.roles && context.team_id in subject.team_ids"
effect = "allow"
priority = 60
# User self-service (priority 40)
[[auth.rbac.policies]]
name = "user-own-resources"
resource = "*"
action = "*"
condition = "context.owner_id == subject.user_id"
effect = "allow"
priority = 40
# Read access for org members (priority 20)
[[auth.rbac.policies]]
name = "org-member-read"
resource = "*"
action = "read"
condition = "context.org_id in subject.org_ids"
effect = "allow"
priority = 20
[auth.bootstrap]
admin_identities = ["admin@example.com"]
[auth.bootstrap.initial_org]
slug = "default"
name = "Default Organization"
admin_identities = ["admin@example.com"]