Skip to main content

Users & Authentication

homeCore authenticates REST and WebSocket callers with JWTs (HS256) or API keys, and authorises each route against a scope granted by the caller's role.

First login

On first boot — meaning the user store is empty — homeCore creates an admin account, writes the generated password to INITIAL_ADMIN_PASSWORD next to the state database (mode 0600), and prints it once in the startup banner.

Delete that file after you log in. homeCore never writes it again.

Roles

Seven roles, each a fixed bundle of scopes. The wire form is snake_case (device_operator), which is what the API accepts and returns.

RoleWhat it is for
adminEverything, including users, plugins, and API keys belonging to other people
userRead everything, command devices, author automations/scenes/areas. No user or plugin management, no audit log
read_onlyRead devices, automations, dashboards, scenes, and areas. Nothing else — no plugins, no audit
observerread_only plus plugins:read and audit:read. A good default for dashboards and observability services
device_operatorobserver plus devices:write — drives devices but cannot author automation logic. The shape a wall panel or kiosk account wants
rule_editorobserver plus authoring automations, scenes, and areas. Cannot command devices
service_operatoruser plus audit:read. The usual envelope for a service account

Scopes by role

Scopeadminuserread_onlyobserverdevice_operatorrule_editorservice_operator
devices:read
automations:read
dashboards:read
scenes:read
areas:read
devices:write
automations:write
dashboards:write
scenes:write
areas:write
plugins:read
plugins:write
audit:read
users:read
users:write
api_keys:admin

A 403 Forbidden means the caller authenticated fine but the role does not carry the scope the route requires. GET /api/v1/auth/roles returns this table from the running server, which is what the web UI reads rather than mirroring it.

Managing your own API keys needs no scope — authenticating as yourself is sufficient. api_keys:admin is only for managing keys owned by someone else.

Public routes, no authentication at all: GET /health, GET /system/versions, POST /auth/login, POST /auth/refresh, and POST /webhooks/{path} — where the path segment is the shared secret, so treat it as one.

GET /metrics sits outside the token system too, gated instead by [metrics].whitelist and returning 403 to everyone by default. See Metrics.

The event stream, the log stream, and the plugin-command SSE stream all require a token, but take it as a ?token= query parameter — a browser cannot set headers on a WebSocket upgrade or an EventSource request.

Logging in

TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"YOUR_PASSWORD"}' | jq -r .token)

The response carries both tokens:

{
"token": "eyJhbGci…",
"token_type": "Bearer",
"expires_in": 86400,
"refresh_token": "…",
"refresh_expires_in": 2592000
}

Use the access token on every subsequent request:

curl -s http://localhost:8080/api/v1/devices \
-H "Authorization: Bearer $TOKEN" | jq

Login is rate-limited per source IP: five attempts in sixty seconds, then HTTP 429 with Retry-After. Behind a reverse proxy that does not forward the client IP this becomes a global cap — pass the real IP through, or rate-limit at the proxy instead.

Refreshing

Access tokens are short-lived; refresh tokens last refresh_token_expiry_days (default 30).

curl -s -X POST http://localhost:8080/api/v1/auth/refresh \
-H "Content-Type: application/json" \
-d "{\"refresh_token\":\"$REFRESH\"}" | jq

Refresh tokens are single-use and rotating: each successful refresh returns a new one and invalidates the old. If an already-used token is presented again, homeCore treats it as theft and revokes the whole chain it belongs to, so a stolen token cannot outlive its owner's next refresh.

Managing users

# List
curl -s http://localhost:8080/api/v1/auth/users \
-H "Authorization: Bearer $TOKEN" | jq

# Create — a dashboard account that can read and see plugin health
curl -s -X POST http://localhost:8080/api/v1/auth/users \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"username":"dashboard","password":"at-least-8-chars","role":"observer"}' | jq

# Create — a wall panel that drives devices but cannot edit rules
curl -s -X POST http://localhost:8080/api/v1/auth/users \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"username":"kitchen-panel","password":"at-least-8-chars","role":"device_operator"}' | jq

Users are addressed by UUID, not username — GET /auth/users gives you the id:

# Change a role
curl -s -X PATCH http://localhost:8080/api/v1/auth/users/$UID/role \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"role":"read_only"}' | jq

# Reset someone's password (admin)
curl -s -X PATCH http://localhost:8080/api/v1/auth/users/$UID/password \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"new_password":"new-password"}' | jq

# Change your own
curl -s -X POST http://localhost:8080/api/v1/auth/change-password \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"current_password":"old","new_password":"new-password"}' | jq

# Delete
curl -s -X DELETE http://localhost:8080/api/v1/auth/users/$UID \
-H "Authorization: Bearer $TOKEN"

Passwords are stored as Argon2id (m=64 MiB, t=3, p=4) with a per-password salt. The minimum length is 8 characters.

Changing a password ends every session

A password change or admin reset bumps the user's token_version, which invalidates every access token already issued to them, and revokes their refresh tokens outright. Whoever holds a stolen token loses it at that moment — which is the point — but it also means changing your own password signs out your other browsers and any script using that account's token.

A WebSocket that is already connected is authorised at upgrade time and runs until it disconnects. It cannot reconnect.

API keys

For scripts and services, an API key is better than a stored password: it carries a subset of your scopes, can expire, can be pinned to source CIDRs, and can be revoked on its own.

curl -s -X POST http://localhost:8080/api/v1/auth/api-keys \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"label": "grafana",
"scopes": ["devices:read"],
"expires_in_days": 365,
"allowed_cidrs": ["10.0.0.42/32"]
}' | jq

The plaintext key (hc_sk_…) is returned once, at creation. It is stored hashed with Argon2id and cannot be recovered — rotate it (POST /auth/api-keys/{id}/rotate) if it is lost.

Use it exactly like a JWT:

curl -s http://localhost:8080/api/v1/devices \
-H "Authorization: Bearer hc_sk_…" | jq

Requested scopes must be a subset of the owner's. Revoke with DELETE /auth/api-keys/{id}.

The IP whitelist — tokenless admin, deprecated

Any request whose source IP matches [auth].whitelist is granted full Admin access with no token at all:

[auth]
whitelist = ["127.0.0.1/32", "10.0.10.14/32"]
List individual addresses, never a subnet

whitelist = ["10.0.10.0/24"] hands unauthenticated admin to every device on that subnet — the TV, the doorbell, a guest's laptop, and anything that joins later. Write out the specific hosts that need it.

Note also that this applies to core's own port, wherever that is. If a reverse proxy fronts homeCore, the proxy's port can look authentication-only while core's port is wide open to the whitelist. Check what is actually listening, not just the address you browse to.

A Bearer token, when present, always wins: whitelisted callers who present a token get their real claims, which is what makes identity-sensitive endpoints like change-password behave correctly.

This option is deprecated and logs a warning on every use. Prefer either:

  • [auth.admin_uds] — an admin-only Unix socket for same-host tooling (hc-cli), authorised by filesystem permissions instead of network identity:

    [auth.admin_uds]
    enabled = true
    path = "/run/homecore/admin.sock"
    group = "homecore-admin"
    mode = "0660"
  • an API key with only the scopes the caller actually needs.

WebSocket authentication

ws://homecore.local:8080/api/v1/events/stream?token=<jwt>

The token is validated before the upgrade is accepted, including its token_version, so a session invalidated by a password change cannot open a stream. An invalid or missing token returns HTTP 401 and no WebSocket is established.

Token lifetime and the signing secret

[auth].token_expiry_hours (default 24) sets the access-token lifetime; refresh_token_expiry_days (default 30) the refresh token's.

The HS256 signing secret is generated once and persisted, to <state-db-parent>/jwt_secret with mode 0600, so tokens survive restarts with no configuration at all. Point [auth].jwt_secret_file somewhere else if you want it elsewhere.

Setting [auth].jwt_secret inline still works and takes precedence, but is deprecated and warns at startup — it puts a secret in a config file for no benefit over the managed one.

Deleting the secret file invalidates every issued token, which is the blunt instrument if you ever need it.

Audit log

Every administrative action — logins, failed logins, user changes, key creation, config writes — is recorded. See Audit log. Retention is [auth].audit_retention_days (default 365), pruned by a background task every six hours.