Authentication Configuration
Auth modes, API keys, IAP, IdP sessions, and CEL-based RBAC policies
Configure authentication and authorization for Hadrian in hadrian.toml using the [auth] section. For conceptual overviews and guides, see:
Auth Mode
Hadrian uses a single [auth.mode] to control authentication for both the API and the web UI. Select exactly one mode.
| Mode | type | API Keys | IdP Sessions | Identity Headers | Use Case |
|---|---|---|---|---|---|
| None | none | No | No | No | Local development |
| API Key | api_key | Yes | No | No | Programmatic access only |
| IdP | idp | Yes | Yes | No | Full deployment with SSO + API |
| IAP | iap | Yes | No | Yes | Behind an identity-aware proxy |
None
Allow all requests without credentials. Only suitable for local development.
[auth.mode]
type = "none"Never use type = "none" in production. All requests are unauthenticated and usage cannot be
tracked or billed.
API Key
Validate API keys stored in the database. Keys are created via the Admin API or UI. No browser-based login is available in this mode.
[auth.mode]
type = "api_key"API key settings (header name, prefix, caching) are configured separately in [auth.api_key]. See API Key Settings.
IdP
Full identity provider integration with per-organization SSO. Supports both API keys for programmatic access and browser sessions for the web UI. Each organization configures its own OIDC or SAML provider via the Admin UI.
[auth.mode]
type = "idp"Session settings (cookie name, duration, secret) are configured in [auth.session]. See Session Configuration.
When a bearer token is presented, Hadrian uses format-based detection to determine whether it is an API key or a JWT:
X-API-Keyheader: Always validated as an API keyAuthorization: Bearerheader: Tokens starting with the configured API key prefix (default:gw_) are validated as API keys; all other tokens are validated as JWTs via per-org SSO configuration
Providing both X-API-Key and Authorization headers simultaneously results in a 400 error
(ambiguous credentials). Choose one authentication method per request.
# 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 (routed to org's IdP for validation)
curl -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..." https://gateway.example.com/v1/chat/completionsLogin 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
With idp mode, SSO is configured per-organization via the Admin UI. Each organization can
have its own OIDC or SAML identity provider. Users authenticate by entering their email, which
discovers their organization's SSO configuration. See the SSO Admin
Guide.
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 receive an error.
To move a user to a different organization, first remove them from their current organization.
IAP (Identity-Aware Proxy)
Trust identity headers set by an authenticating reverse proxy. Also supports API keys for programmatic access. Works with:
- Cloudflare Access
- oauth2-proxy
- Tailscale
- Authelia / Authentik
- Keycloak Gatekeeper
- Pomerium
[auth.mode]
type = "iap"
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.mode]
type = "iap"
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.mode]
type = "iap"
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. Configure jwt_assertion inside [auth.mode]:
[auth.mode]
type = "iap"
identity_header = "X-Forwarded-User"
[auth.mode.jwt_assertion]
header = "Cf-Access-Jwt-Assertion"
jwks_url = "https://acme-corp.cloudflareaccess.com/cdn-cgi/access/certs"
issuer = "https://acme-corp.cloudflareaccess.com"
audience = "your-audience-id"API Key Settings
Configure shared API key behavior used by api_key, idp, and iap modes.
[auth.api_key]
header_name = "X-API-Key"
key_prefix = "gw_"
generation_prefix = "gw_live_"
hash_algorithm = "sha256"
cache_ttl_secs = 300| 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 | 300 | 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.
Per-Org JWT Routing
When using idp mode, JWT validation is handled per-organization through SSO configurations -- there is no global JWT config in hadrian.toml. Each organization's SSO config provides the issuer, audience, and JWKS URL for validating JWTs from that organization's identity provider.
When a JWT is presented on a /v1/* endpoint:
- Hadrian decodes the
iss(issuer) claim from the token - Looks up the organization whose SSO config matches that issuer
- Validates the token against that organization's JWKS
- Associates the request with the matched organization
This enables true multi-tenant deployments where each organization uses a different identity provider without any global JWT configuration.
Configure each organization's SSO via the Admin UI or API. See the SSO Admin Guide for setup instructions.
Session Configuration
Configure browser session settings for idp mode. Sessions are created after successful SSO login.
[auth.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 Provisioning
JIT (Just-in-Time) provisioning automatically creates users and adds them to organizations when they first authenticate via SSO. JIT provisioning is configured per-organization via the Admin UI or Admin API, not in hadrian.toml.
See the SSO Admin Guide for instructions on configuring JIT provisioning per organization.
Each organization's JIT provisioning settings include:
| Setting | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | Enable JIT provisioning for this organization. |
create_users | boolean | false | Create users in the database on first login. |
default_team_id | string | None | Team to add users to on first login. |
default_org_role | string | member | Role assigned when added to the organization. |
default_team_role | string | member | Role assigned when added to the default team. |
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. |
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.
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. Use idp mode to support both API keys and JWTs:
[auth.mode]
type = "idp"
[auth.api_key]
key_prefix = "gw_"API key authentication alone does not provide role information. Use JWT authentication (via
per-org SSO) 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@acme.com", "bob@acme.com"]
[auth.bootstrap.initial_org]
slug = "acme-corp"
name = "Acme Corporation"
admin_identities = ["alice@acme.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.
Bootstrap API Key Generation
Create an API key during bootstrap for programmatic access:
[auth.bootstrap]
api_key = "${HADRIAN_BOOTSTRAP_KEY}"
auto_verify_domains = ["acme.com"]
[auth.bootstrap.initial_org]
slug = "acme-corp"
name = "Acme Corporation"
admin_identities = ["admin@acme.com"]
[auth.bootstrap.initial_api_key]
name = "production-api-key"| Setting | Type | Description |
|---|---|---|
initial_api_key.name | string | Name for the auto-created API key (scoped to the initial org) |
The generated API key is printed to stdout on first creation. Subsequent runs skip creation if a key with the same name already exists.
Bootstrap SSO Configuration
Pre-configure SSO for the initial organization directly in the config file, avoiding manual Admin UI setup:
[auth.bootstrap.initial_org]
slug = "acme-corp"
name = "Acme Corporation"
admin_identities = ["admin@acme.com"]
[auth.bootstrap.initial_org.sso]
provider_type = "oidc"
issuer = "https://accounts.google.com"
client_id = "${OIDC_CLIENT_ID}"
client_secret = "${OIDC_CLIENT_SECRET}"
redirect_uri = "https://gateway.example.com/auth/callback"
discovery_url = "https://accounts.google.com/.well-known/openid-configuration"
allowed_email_domains = ["acme.com"]| Setting | Type | Description |
|---|---|---|
sso.provider_type | string | "oidc" or "saml" |
sso.issuer | string | IdP issuer URL |
sso.client_id | string | OAuth client ID |
sso.client_secret | string | OAuth client secret (stored in secrets manager) |
sso.redirect_uri | string | OAuth redirect URI |
sso.discovery_url | string | OIDC discovery endpoint (optional) |
sso.allowed_email_domains | string[] | Restrict SSO login to these email domains |
Domains listed in both auto_verify_domains and sso.allowed_email_domains are automatically verified during bootstrap.
Bootstrap CLI
Run bootstrap as a standalone CLI command instead of at server startup. This is the recommended approach for GitOps and IaC workflows:
# Run bootstrap against the database
hadrian bootstrap --config hadrian.toml
# Preview what would be created without making changes
hadrian bootstrap --config hadrian.toml --dry-runThe hadrian bootstrap command:
- Connects directly to the database (no HTTP server started)
- Runs pending migrations before bootstrapping
- Creates the initial organization, SSO config, and API key as specified in
[auth.bootstrap] - Is fully idempotent — safe to run repeatedly (skips resources that already exist)
- Prints the generated API key to stdout on first creation (pipe to a secret manager or file)
# Example: capture the generated API key
API_KEY=$(hadrian bootstrap --config hadrian.toml 2>/dev/null)
echo "Generated key: $API_KEY"Use --dry-run to verify your bootstrap configuration before applying it to a production
database.
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@acme.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.mode]
type = "none"Production API Keys Only
[auth.mode]
type = "api_key"
[auth.api_key]
header_name = "X-API-Key"
key_prefix = "gw_"
cache_ttl_secs = 300IdP with Per-Org SSO
Each organization configures their own OIDC or SAML provider via the Admin UI. API keys provide programmatic access.
[auth.mode]
type = "idp"
[auth.api_key]
key_prefix = "gw_"
cache_ttl_secs = 300
[auth.session]
secure = true
secret = "${SESSION_SECRET}"
[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 = 50
[auth.bootstrap]
api_key = "${HADRIAN_BOOTSTRAP_KEY}"
auto_verify_domains = ["acme.com"]After deploying, use the bootstrap API key to create organizations and configure SSO. Once SSO is active, use the Admin UI to set up group mappings that map IdP 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.mode]
type = "iap"
identity_header = "Cf-Access-Authenticated-User-Email"
email_header = "Cf-Access-Authenticated-User-Email"
[auth.mode.jwt_assertion]
header = "Cf-Access-Jwt-Assertion"
jwks_url = "https://acme-corp.cloudflareaccess.com/cdn-cgi/access/certs"
issuer = "https://acme-corp.cloudflareaccess.com"
audience = "your-app-audience-tag"
[auth.api_key]
key_prefix = "gw_"Multi-Tenant with Full RBAC
[auth.mode]
type = "idp"
[auth.api_key]
key_prefix = "gw_"
cache_ttl_secs = 300
[auth.session]
secure = true
secret = "${SESSION_SECRET}"
[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.rbac.gateway]
enabled = true
default_effect = "allow"
[auth.bootstrap]
admin_identities = ["admin@acme.com"]
[auth.bootstrap.initial_org]
slug = "acme-corp"
name = "Acme Corporation"
admin_identities = ["admin@acme.com"]
[auth.bootstrap.initial_api_key]
name = "admin-key"Multi-Org with Per-IdP API Authentication
Each organization configures their own identity provider via the Admin UI. No global JWT config is needed -- per-org SSO configs automatically enable JWT validation on /v1/* endpoints.
[auth.mode]
type = "idp"
[auth.api_key]
key_prefix = "gw_"
cache_ttl_secs = 300
# No global JWT config -- per-org SSO configs provide JWT validation for each org's IdP.
[auth.session]
secure = true
same_site = "lax"
secret = "${SESSION_SECRET}"
[auth.rbac]
enabled = true
default_effect = "deny"
[[auth.rbac.policies]]
name = "super-admin"
resource = "*"
action = "*"
condition = "'super_admin' in subject.roles"
effect = "allow"
priority = 100
[[auth.rbac.policies]]
name = "org-isolation"
resource = "*"
action = "*"
condition = "context.org_id in subject.org_ids"
effect = "allow"
priority = 10
[auth.bootstrap]
api_key = "${HADRIAN_BOOTSTRAP_KEY}"
auto_verify_domains = ["acme.com", "globex.io"]After deploying, use the bootstrap API key to create organizations and their SSO configs. Once SSO is configured, each org's users can authenticate with both the web UI (via SSO login) and the API (via JWT tokens from their org's IdP).