Auth & security

Dual-key API auth, protected local controls, CORS, network allowlists, and rate limiting for the Hypercolor daemon on :9420.

The daemon ships open for ordinary control on loopback and closed to the network. Local clients on 127.0.0.1 (CLI, TUI, web UI, an MCP client on the same box) can manage lighting with no credentials. Privacy-bearing capture and input operations always require an authenticated control credential, including on loopback. Every off-host request is also gated by API-key authentication, a per-client allowlist, CORS, and rate limiting. One Axum middleware (enforce_security) establishes both the ordinary API tier and the separate protected-control authority for the whole /api/v1 surface.

If you only ever drive Hypercolor from the same machine, you can stop reading after the loopback section. Everything else matters the moment you bind the daemon to a LAN address or put it behind a reverse proxy.

Ordinary API authentication is opt-in. With no API-key environment variables set, loopback lighting control remains credentialless and remote clients are governed by the network allowlist (default: local-only). Protected capture and input surfaces remain unavailable until a control credential or trusted in-process capability is present.

#The model at a glance

A request flows through these checks in order. The first one that fails returns immediately with a { error, meta } envelope (see Envelope & errors).

graph TD
  A[Incoming request] --> B{Allowed by network policy?}
  B -- no --> R1[403 forbidden]
  B -- yes --> C{Bearer-exempt path?}
  C -- yes --> P[Handler]
  C -- no --> D{Loopback client?}
  D -- yes --> E{Cross-site mutating request?}
  E -- yes --> R2[403 forbidden - CSRF]
  E -- no --> J{Protected capture or input?}
  D -- no --> F{Auth enabled?}
  F -- no --> J
  F -- yes --> G{Valid Bearer token?}
  G -- no --> R3[401 unauthorized]
  G -- yes --> H{Tier satisfies method?}
  H -- no --> R4[403 forbidden]
  H -- yes --> I{Under rate limit?}
  I -- no --> R5[429 rate_limited]
  I -- yes --> J
  J -- no --> P
  J -- yes --> K{Authenticated control authority?}
  K -- no --> R6[403 forbidden]
  K -- yes --> P

#Dual-key authentication

Authentication is configured entirely through two environment variables read at daemon startup:

VariableTierGrants
HYPERCOLOR_API_KEYControlRead and write (every method)
HYPERCOLOR_READ_API_KEYReadGET, HEAD, OPTIONS only

Authentication is enabled when either variable holds a non-blank value. Whitespace-only values are treated as unset. With neither set, the API-key gate is bypassed and only the network allowlist applies.

# Control tier only: one key that can do everything
HYPERCOLOR_API_KEY="hc_ak_super_secret" hypercolor-daemon

# Split tiers: a write key plus a read-only key for dashboards/scripts
HYPERCOLOR_API_KEY="hc_ak_super_secret" \
HYPERCOLOR_READ_API_KEY="hc_ak_r_dashboard_only" \
  hypercolor-daemon

#Bearer scheme

Authenticated requests carry the token in a standard Authorization header:

GET /api/v1/devices HTTP/1.1
Host: studio.local:9420
Authorization: Bearer hc_ak_super_secret

The scheme keyword is case-insensitive (Bearer, bearer); the token must be non-empty. The CLI’s --api-key flag (env HYPERCOLOR_API_KEY) sets this header for you on every request, so a remote CLI session looks like:

hypercolor --host studio.local --api-key "hc_ak_super_secret" devices list

#Tier resolution and the read-prefix rule

The tier a token grants is resolved against the configured keys:

  • A token matching HYPERCOLOR_READ_API_KEY always grants the read tier.
  • A token matching HYPERCOLOR_API_KEY normally grants the control tier, unless that key string begins with the prefix hc_ak_r_, in which case it is treated as read-only even though it sits in the control slot. Use the hc_ak_r_ convention to name keys you intend to be read-only and the daemon will enforce that intent.

A read-tier token that hits a mutating method (POST/PUT/PATCH/DELETE) gets 403 forbidden with a detail body naming the required and current tiers:

{
  "error": {
    "code": "forbidden",
    "message": "Read-only API key cannot perform write operations",
    "details": { "required_tier": "control", "current_tier": "read" }
  },
  "meta": { "api_version": "1.0", "request_id": "req_…", "timestamp": "…Z" }
}

A missing or unparseable token on a non-loopback request returns 401 unauthorized.

#The loopback exemption

Requests whose client IP is loopback (127.0.0.0/8, ::1) skip the API-key requirement for ordinary lighting control. This is why the CLI, TUI, web UI, and a local MCP client all work with no key on a default install. The daemon derives the client IP from the peer socket; when the peer is itself loopback (a reverse proxy on the same host) it honors X-Forwarded-For / X-Real-IP so the real remote IP is used for auth and allowlisting. Forwarded headers from a non-loopback peer are ignored, so you cannot spoof your way to a loopback exemption.

Loopback locality is not a user identity. These surfaces require protected control authority even when the TCP peer is loopback:

  • POST /api/v1/input/authorize
  • POST /api/v1/capture/authorize
  • PUT /api/v1/capture/source
  • GET /api/v1/capture/monitors
  • WebSocket subscriptions to screen_canvas, screen_zones, or input_events

The system endpoint remains available. Its public identity needs no key, its status block needs read authority, and capture selection IDs stay redacted unless the request has protected control authority.

An authenticated control-tier key grants that authority. A read key, a missing key, an Origin header, Fetch Metadata, and the peer IP do not. Trusted in-process transports receive the same authority only after their own authentication boundary has succeeded.

#CSRF protection on loopback

Trusting loopback would let a malicious web page in the user’s browser issue drive-by writes to http://localhost:9420. To block that, cross-site mutating requests to the loopback API are rejected with 403 forbidden. The daemon keys this on the browser-set Sec-Fetch-Site: cross-site header: the bundled web UI (same-origin) and non-browser clients (CLI, SDK, which omit the header) are unaffected; only a browser explicitly marking the request cross-site is denied.

The CSRF guard fires for any mutating loopback request marked cross-site, even when no API key is configured. A page on another origin cannot POST to your local daemon to install an effect or change a scene.

#Network access policy

Before authentication, every request passes the network allowlist. By default the daemon is local-only: loopback is always allowed, and there is no allowlist entry for anything else, so off-host requests are simply unreachable because the daemon binds loopback. Opening the daemon to the LAN is a deliberate config change under [network].

KeyTypeDefaultMeaning
network.access_modeenumlocal_onlylocal_only, lan_trusted, lan_protected, custom
network.client_scopeenumlocal_subnetsWhich built-in IP scope to trust: local_subnets, private_ranges, custom
network.remote_accessboolfalseForce remote access on without changing access_mode
network.allowed_clientslist[]Extra exact IPs or CIDR rules, e.g. ["192.168.1.0/24", "10.0.0.5"]
network.allow_unauthenticated_remote_accessboolfalsePermit remote clients without a key (see warning)
network.mdns_publishbooltrueAdvertise the daemon over mDNS

How the scopes resolve when remote access is on:

  • local_subnets trusts the CIDR of every non-loopback interface the host currently has. This is the narrowest “let my LAN in” option.
  • private_ranges trusts the RFC 1918 / link-local / ULA ranges: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, fc00::/7, fe80::/10.
  • custom trusts only the explicit allowed_clients you list; no built-in scope is added.

allowed_clients entries are always layered on top of the resolved scope. A non-loopback client whose IP matches no rule gets 403 forbidden with a detail body naming the rejected client_ip. Loopback is exempt from the allowlist in every mode.

[network]
access_mode = "lan_trusted"
client_scope = "local_subnets"
allowed_clients = ["192.168.1.0/24"]

access_mode = "lan_trusted" allows unauthenticated remote access by design: any client on the trusted subnets can control your lights without a key. For a network-reachable daemon you usually want lan_protected (remote allowed, but a key is required) plus HYPERCOLOR_API_KEY. Only set allow_unauthenticated_remote_access = true if you genuinely want keyless LAN control and understand the exposure.

#Fail-closed startup binds

The bind address itself is enforced at startup, so a network-reachable daemon without auth never comes up by accident. The validation (validate_network_bind_auth in the daemon) refuses to bind the control API to any non-loopback address unless HYPERCOLOR_API_KEY is configured or network.allow_unauthenticated_remote_access = true is set; an explicit non-loopback bind flag (--bind, --listen-address, --listen-all) without either aborts startup with an error naming those exact remedies.

A non-loopback target that comes from config rather than a flag degrades instead of aborting: the daemon falls back to loopback at startup and logs a warning naming the requested and effective targets. Either way the daemon fails closed. It serves loopback or it serves an authenticated network bind, never an open one.

#CORS

The daemon sends permissive CORS headers for loopback origins always (http://localhost:*, http://127.0.0.1:*, and the IPv6 loopback). Additional browser origins are honored only when API authentication is enabled and the origin is listed in web.cors_origins:

[web]
cors_origins = ["https://studio.example.com"]

Each configured origin must be a bare scheme://host[:port] with an http or https scheme and no path; malformed entries are logged and dropped. The allowed methods are GET, HEAD, OPTIONS, POST, PUT, PATCH, DELETE, and the allowed request headers are Accept, Authorization, and Content-Type. When auth is off, configured origins are ignored; only loopback gets CORS, matching the local-only posture.

#Rate limiting

Authenticated, non-loopback traffic is rate-limited per client IP over a rolling 60-second window. Limits are tracked separately by operation class:

ClassLimit / windowScope
Read (GET/HEAD/OPTIONS)120Per client
Write (other mutating routes)60Per client
Pairing (/api/v1/devices/{id}/pair)6Per client
Discovery (POST /api/v1/devices/discover)2Global

Discovery is intentionally a single global budget: a network rescan is expensive, so two per minute is the ceiling across all clients combined, not per client.

Every rate-limited response carries the standard headers, and a rejection adds Retry-After:

X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1750800000
Retry-After: 37

Exceeding a limit returns 429 with the rate_limited error code:

{
  "error": {
    "code": "rate_limited",
    "message": "Write operation rate limit exceeded. Retry in 37 seconds.",
    "details": { "limit": 60, "window_seconds": 60, "retry_after": 37 }
  },
  "meta": { "api_version": "1.0", "request_id": "req_…", "timestamp": "…Z" }
}

#Exempt paths

Some paths do not require a bearer token. Every one of them still passes the network access policy first, so an exemption is from presenting a key, never from being allowed to reach the daemon at all. Exempt paths are not rate limited, which is deliberate: a single page load pulls more asset requests than the read limit allows.

The health route bypasses the security stack entirely so liveness checks work regardless of configuration:

  • GET /health: liveness probe.

GET /api/v1/system still passes network policy and read rate limiting. On a keyed daemon, an anonymous remote request receives only the public identity; a valid read or control key adds the status block. Any supplied invalid key returns 401 unauthorized.

Two more are exempt because a browser cannot authenticate them. A page loads its scripts, stylesheets, and fonts through tags that carry no Authorization header, so a keyed daemon that guarded them would serve an interface no browser could render:

  • The bundled web UI and its assets, whenever a UI directory is configured. The UI is served as the router’s fallback, so this covers the shell, its hashed asset bundles, and every SPA deep link.
  • GET /api/v1/docs (Swagger UI) and GET /api/v1/openapi.json, which the docs page fetches as a second request.

The asset exemption is defined by subtraction: a path is an asset when no dynamic mount claims it. The daemon derives that list where the routes are mounted, so the API surface, /health, and the MCP mount at its configured base path are never inside it. The UI shell loads without a key; every API call the UI then makes still needs one.

The MCP server (mounted at /mcp when mcp.enabled is true) sits outside the /api/v1 middleware stack. MCP is off by default; enable it before using any agent integration. See MCP setup for the transport and client configuration.

#WebSocket authentication

The /api/v1/ws upgrade is the one endpoint that accepts a token in the query string, because browsers cannot set custom headers on a WebSocket handshake:

ws://studio.local:9420/api/v1/ws?token=hc_ak_super_secret

Query-string tokens are accepted only on the GET WebSocket upgrade. Plain HTTP endpoints reject ?token= and demand the Authorization header, so a token never leaks into an ordinary request URL or access log. A loopback socket needs no token for ordinary channels. The three sensitive channels require a control token in the upgrade URL or a trusted in-process connection. For the channel and frame protocol once connected, see WebSocket protocol.

#Hardening checklist

For a daemon you intend to reach over the network:

  1. Set HYPERCOLOR_API_KEY to a long random secret, and name read-only keys with the hc_ak_r_ prefix or put them in HYPERCOLOR_READ_API_KEY.
  2. Choose access_mode = "lan_protected" so remote clients must authenticate; reserve lan_trusted for genuinely keyless LANs.
  3. Narrow client_scope (prefer local_subnets) and add explicit allowed_clients CIDRs for the hosts that should reach the daemon.
  4. List only the exact browser origins you trust in web.cors_origins.
  5. Terminate TLS at a reverse proxy on the same host so the daemon sees a loopback peer and reads the real client IP from X-Forwarded-For.

For the rest of the contract this auth layer guards, see the REST API reference, the envelope and error codes, and the CLI reference.