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 prefix for 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_at stored 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

RolePurpose
adminFull access — all operations including key management
operatorAgent lifecycle, workflow management, schedules, backups, infra management
auditorRead-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
developerInstall skills/competencies, create workflows, invoke agents
serviceInvoke agents, start workflow runs only — narrowest surface, for CI/CD and service-to-service keys
viewerRead-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)

ActionAdminOperatorAuditorDeveloperServiceViewer
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:

ParameterDefaultConfig Key
Base backoff1 secondauth.rate_limit.base_backoff_secs
Max backoff300 seconds (5 min)auth.rate_limit.max_backoff_secs
Max failures tracked10auth.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 = false and 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/health endpoint sharing that address — the middleware exempts only paths starting with /health, so a surface's own /health route always bypasses, but /metrics does not: if you set allowed_ips on 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 no SocketAddr for 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 * with allow_credentials = true
  • Each origin must start with http:// or https://
  • Empty origins list is rejected when CORS is configured

Request Limits

[surfaces.api.limits]
max_request_body_bytes = 10485760
request_timeout_secs = 600
LimitDefaultPurpose
max_request_body_bytes10 MBRejects 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_secs600 (10 min)Cuts off a slow request with 408 via tower_http::timeout::TimeoutLayer

Credential Security

API Key Secret Flow

Key principles:

  • config.toml stores the name of the env var (api_key_env = "ANTHROPIC_API_KEY"), never the value
  • Every call site (provider api_key_env, registry auth_token_env) resolves through the single resolve_env_secret chokepoint — vault first, process-env fallback — rather than reading std::env::var independently
  • 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 by vault:manage (write/delete) and vault:read (list), both admin-only. There is no GET /vault/{key} — a stored value can never be read back once written.
  • Resolution: resolve_env_secret checks the vault first, then falls back to the process environment. config.toml never 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|env metadata, so which mechanism supplied a given credential is always traceable.
  • CLI: hoziron-cli vault set/list/remove are thin clients against this API — nothing is read from or written to the local filesystem by the CLI itself. hoziron-cli vault init is a no-op; the vault is created lazily by the daemon on first write.

Vault vs Environment

MethodStored WhereBest For
Vault$HOZIRON_HOME/vault.db (encrypted at rest, daemon-owned)Bare metal, persistent, local development
Environment variablesProcess envContainers, 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:

EndpointReason
GET /healthOrchestrator liveness/readiness probes must always work
GET /metricsPrometheus 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 Permission system
  • 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