Security & Authentication
How Hoziron authenticates requests, enforces role-based access, protects against brute force attacks, and manages secrets.
Authentication Modes
Hoziron supports three authentication modes, configured via [auth].mode:
Disabled (default)
No authentication. All requests receive anonymous admin access. Suitable for local development and single-user deployments.
Local (API Key)
Hoziron-managed API keys with role-based access control.
[auth]
mode = "local"
[auth.rate_limit]
base_backoff_secs = 1
max_backoff_secs = 300
max_failed_attempts = 10
OIDC (Enterprise SSO)
JWT validation against an external Identity Provider (Azure AD, Keycloak, Okta, Auth0).
[auth]
mode = "oidc"
allow_local_service_keys = true # Allow API keys alongside OIDC for CI/CD
[auth.oidc]
issuer = "https://login.microsoftonline.com/{tenant}/v2.0"
audience = "api://hoziron-platform"
jwks_uri = "" # Auto-discovered if blank
role_claim = "roles" # Supports dot-path: "realm_access.roles"
allowed_algorithms = ["RS256", "ES256"]
jwks_cache_ttl_secs = 3600
[auth.oidc.role_mapping]
"HozironAdmins" = "admin"
"PlatformOps" = "operator"
"Developers" = "developer"
"ReadOnly" = "viewer"
API Key System
Key Generation
- 32 bytes of cryptographic randomness (via OS RNG)
- Base64url-encoded with
hzk_prefix:hzk_Ab3xYz... - Only shown once at creation — never stored or retrievable after
- First 12 characters stored as
prefixfor identification without exposure
Key Storage
- SQLite database at
$HOZIRON_HOME/data/auth.db - File permissions set to 0600 (owner read/write only) on Unix
- Only argon2id hashes are persisted (recommended params: 19 MiB memory, 2 iterations, 1 parallelism)
- Indexed by prefix for fast lookup, but authentication always scans ALL keys
Timing Attack Prevention
Authentication performs a constant-time scan of all active keys:
for each key in active_keys:
verify(token, key.hash) // argon2id verify (constant-time)
if match: record (but don't return early)
return recorded match or None
An attacker cannot determine how many keys exist or which position a valid key occupies based on response time.
Lockout Protection
The last admin key cannot be revoked. Attempting to revoke it returns:
{
"error": {
"category": "ValidationError",
"message": "Cannot revoke the last admin key — this would lock out all admin access"
}
}
Bootstrap Flow
When no keys exist yet, the first POST /auth/keys request is allowed without authentication (bootstrap bypass). This prevents the chicken-and-egg problem of needing a key to create the first key.
Key Expiration
Keys can optionally expire:
expires_atstored as ISO 8601- Checked at validation time — expired keys are treated as invalid
- Fail-closed on malformed expiry values (treated as expired)
Role-Based Access Control (RBAC)
RBAC is a hardcoded role model (ADR-025) — six fixed roles with a compile-time permission matrix (PERMISSION_MATRIX in crates/platform/hoziron-core-types/src/auth/permissions.rs). There is no admin-defined custom role and no runtime permission editing; role expansion happens via code change and release. This is a deliberate compliance-predictability choice: a regulatory auditor's "who can do X" question has one deterministic, code-reviewable answer, and enterprises fit their own AD/Okta groups onto these six roles via OIDC role_mapping rather than the platform exposing raw permission primitives.
Roles
| Role | Purpose |
|---|---|
admin | Full access — all operations including key management |
operator | Agent lifecycle, workflow management, schedules, backups, infra management |
auditor | Read-only, with full audit/security/compliance visibility — zero mutation. Designed for a compliance officer who must verify platform posture independently, without the ability to change any state |
developer | Install skills/competencies, create workflows, invoke agents |
service | Invoke agents, start workflow runs only — narrowest surface, for CI/CD and service-to-service keys |
viewer | Read-only access to operational state (not audit trails or security detail — that's auditor) |
Role Hierarchy (OIDC Priority)
When multiple IdP-mapped roles could apply to one caller, the highest-priority role wins:
admin (6) > operator (5) > auditor (4) > developer (3) > service (2) > viewer (1)
Permission Matrix (excerpt)
| Action | Admin | Operator | Auditor | Developer | Service | Viewer |
|---|---|---|---|---|---|---|
| agent:start / agent:stop | ✓ | ✓ | ||||
| agent:send_message | ✓ | ✓ | ✓ | ✓ | ||
| agent:list | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| workflow:run | ✓ | ✓ | ✓ | ✓ | ||
| competency:install / competency:equip | ✓ | ✓ | ✓ | |||
| config:write / config:reload | ✓ | |||||
| auth:key_management | ✓ | |||||
| audit:read | ✓ | ✓ | ✓ | |||
| audit:write | ✓ | |||||
| backup:create | ✓ | ✓ |
The full Action enum (AgentStart, AgentStop, AgentSendMessage, AgentList, AgentStatus, AgentUpdate, AgentInvoke, WorkflowRun, WorkflowList, WorkflowStatus, CompetencyInstall, CompetencyEquip, CompetencyUnequip, CompetencyManage, SkillInstall, SkillRemove, ConfigRead, ConfigWrite, ConfigReload, KeyManagement, AuditRead, AuditWrite, ScheduleCreate, ScheduleDelete, ScheduleUpdate, PackagePublish, RegistryPublish, RegistryAdmin, RegistryDownload, BackupCreate, and more) each maps to one or more endpoints; Role::can_perform(&Action) is the single source of truth this table is an excerpt of.
Authorization Check
Every endpoint checks auth_context.role.can_perform(action) before executing. Insufficient role returns:
{
"error": "forbidden",
"message": "Role 'viewer' is not authorized for action 'agent:create'"
}
Brute Force Protection
Per-IP exponential backoff on failed authentication attempts:
| Parameter | Default | Config Key |
|---|---|---|
| Base backoff | 1 second | auth.rate_limit.base_backoff_secs |
| Max backoff | 300 seconds (5 min) | auth.rate_limit.max_backoff_secs |
| Max failures tracked | 10 | auth.rate_limit.max_failed_attempts |
Rate limit state is:
- In-memory (per-process, reset on restart)
- Per-IP address
- Cleared on successful auth from that IP
- Stale entries evicted after 10 minutes of inactivity
OIDC / JWT Validation
For enterprise SSO integration:
Role Mapping
The role_claim field supports dot-path traversal for nested claims (Keycloak pattern):
role_claim = "realm_access.roles" # Navigates into nested JWT claims
When multiple IdP roles match the mapping, highest-privilege wins per the role hierarchy above (admin > operator > auditor > developer > service > viewer).
Hybrid Mode
With allow_local_service_keys = true, OIDC mode falls back to local API key validation when JWT validation fails. This enables:
- CI/CD pipelines using API keys alongside human SSO
- Break-glass access for emergencies
Network Security
TLS, IP allowlist, and request limits are configured per surface under
[surfaces.<name>] (api, registry, mcp, dashboard), not in one
shared [server] block — each surface is either a main route that owns
a listener and may set these, or mounted on another surface's listener
and inherits them (Issue #682). See
TLS and networking for the
full main-route/mount topology and validation rules.
TLS
[surfaces.api.tls]
enabled = true
cert_path = "/etc/hoziron/tls/cert.pem"
key_path = "/etc/hoziron/tls/key.pem"
- Native, in-process TLS termination (
axum-server+rustls) for bare-metal/VM deployments, resolved once per merged listener - Certificate and key are hot-reloaded from disk on a 60s poll (e.g. certbot/ACME renewal), no restart needed
- Behind a reverse proxy or load balancer that already terminates TLS: leave
enabled = falseand let it handle TLS - Certificate and key paths are validated at startup; a surface mounted on this one inherits the same TLS termination
IP Allowlist
[surfaces.api]
listen = "0.0.0.0:4200"
allowed_ips = ["10.0.0.0/8", "192.168.1.100"]
- Outermost middleware layer on the merged listener — checked before auth
- Supports individual IPs and CIDR notation (IPv4 and IPv6)
- Covers every surface mounted onto this listener, plus the unified
/__server/healthendpoint sharing that address — the middleware exempts only paths starting with/health, so a surface's own/healthroute always bypasses, but/metricsdoes not: if you setallowed_ipson the API surface's listener, make sure your Prometheus scraper's source IP is included - Cannot be combined with a
unix://listen address on the same surface — Unix-socket connections carry noSocketAddrfor the middleware to check, so this is a hard config-validation error rather than a silent bypass
CORS
[server.cors]
allowed_origins = ["https://dashboard.company.com"]
allow_credentials = true
max_age_secs = 3600
CORS remains a shared [server.cors] setting (not per-surface) and is applied to the API surface and the unified health router only — not automatically to MCP or Dashboard.
Validation rules:
- Cannot use wildcard
*withallow_credentials = true - Each origin must start with
http://orhttps:// - Empty origins list is rejected when CORS is configured
Request Limits
[surfaces.api.limits]
max_request_body_bytes = 10485760
request_timeout_secs = 600
| Limit | Default | Purpose |
|---|---|---|
max_request_body_bytes | 10 MB | Rejects oversized bodies with 413 via tower_http::limit::RequestBodyLimitLayer, checked against Content-Length unconditionally — not axum::extract::DefaultBodyLimit, which only fires if a handler extracts the body |
request_timeout_secs | 600 (10 min) | Cuts off a slow request with 408 via tower_http::timeout::TimeoutLayer |
Credential Security
API Key Secret Flow
Key principles:
config.tomlstores the name of the env var (api_key_env = "ANTHROPIC_API_KEY"), never the value- Every call site (provider
api_key_env, registryauth_token_env) resolves through the singleresolve_env_secretchokepoint — vault first, process-env fallback — rather than readingstd::env::varindependently - Error messages reference the env var name, never the key value
- Both the key store database and the vault have 0600 permissions (owner-only), created with that mode from the moment the file exists — never a broader default mode narrowed by a later
chmod - The resolved key is only ever held in-memory on
llm_driver::DriverConfig.api_key, which is#[serde(skip)]— a future config dump, telemetry snapshot, or structured log can't leak it just by serializing that struct
Credential Vault
The vault is a real, encrypted-at-rest, daemon-owned store — the only supported way to persist a secret outside the process environment. It replaced an earlier plaintext-.env-append implementation.
- Storage: SQLite
$HOZIRON_HOME/vault.db(WAL mode, 0600). Each row is independently salted and nonced and encrypted with AES-256-GCM, so rotating one key never re-encrypts unrelated entries. - Master key:
$HOZIRON_HOME/keys/vault.key(0600), generated on first use, reusing the same Argon2 key-derivation function as the pre-existing integration-credential vault (CredentialVault) rather than a second KDF scheme. - API:
PUT /vault/{key},GET /vault(names + timestamps only),DELETE /vault/{key}— gated byvault:manage(write/delete) andvault:read(list), both admin-only. There is noGET /vault/{key}— a stored value can never be read back once written. - Resolution:
resolve_env_secretchecks the vault first, then falls back to the process environment.config.tomlnever contains a secret value, only the name of the env var to resolve. - Audit: every provider/registry credential resolution emits an audit event with
credential_source: vault|envmetadata, so which mechanism supplied a given credential is always traceable. - CLI:
hoziron-cli vault set/list/removeare thin clients against this API — nothing is read from or written to the local filesystem by the CLI itself.hoziron-cli vault initis a no-op; the vault is created lazily by the daemon on first write.
Vault vs Environment
| Method | Stored Where | Best For |
|---|---|---|
| Vault | $HOZIRON_HOME/vault.db (encrypted at rest, daemon-owned) | Bare metal, persistent, local development |
| Environment variables | Process env | Containers, CI, orchestrator-managed secrets |
config set-key/delete-key and channel setup both store secrets via the vault; neither reads or writes a local .env file anymore.
Endpoints Always Accessible
Regardless of auth mode, IP allowlist, or any security configuration:
| Endpoint | Reason |
|---|---|
GET /health | Orchestrator liveness/readiness probes must always work |
GET /metrics | Prometheus scraping without API key |
POST /auth/keys (first key only) | Bootstrap — create first key without existing auth |
Related:
- permission-model.md — the separate, agent-level
Permissionsystem - pii-data-protection.md — the audit chain this auth layer feeds
docs/decisions/024-unified-http-middleware-and-registry-auth.md,025-rbac-role-model.md