Authentication
By default the Kapkan API and dashboard are unauthenticated. This is safe only because
the default api.listen value binds to 127.0.0.1 — the listener is reachable only from
the host itself, so no token is required to read status, list attacks, or issue manual
bans. The moment you bind the listener to a routable address, that protection disappears.
!Set a token before exposing the listener
If you change api.listen to anything other than localhost — for example 0.0.0.0:8080
or a specific interface — configure a token first. An unauthenticated listener on a
routable address lets anyone reach the API, including the manual ban and unban
endpoints.
Setting a token
The shorthand is a single operator token. Add token_env to the api block — it names the
environment variable that holds the token; the secret itself is read from the process
environment per request, never from the config file:
api:
listen: "0.0.0.0:8080"
token_env: "KAPKAN_API_TOKEN" # token read from this env var, never the file
Then provide the value out of band, for example through your service manager's environment or a secrets file:
export KAPKAN_API_TOKEN="$(openssl rand -hex 32)"
./kapkan -config /etc/kapkan/config.yaml
Keeping the token in an environment variable rather than the YAML means the config remains safe to commit to git and diff alongside the rest of your configuration.
That single token is all most deployments need — the rest of this page (roles, tenants) is optional and can wait.
Roles
For role-based access use tokens instead of token_env — a list where each entry names the
env var holding its secret and a role:
api:
listen: "0.0.0.0:8080"
tokens:
- { name: dashboard, token_env: "KAPKAN_API_RO", role: viewer }
- { name: automation, token_env: "KAPKAN_API_RW", role: operator }
name, token_env and role are all required for each entry; role must be exactly
viewer, operator or agent. A missing or misspelled value is a hard validation error at
startup, not a silent default.
| Role | May do |
|---|---|
viewer | Read only: status, attacks, hosts, bans, metrics. |
operator | Everything a viewer can, plus manual ban/unban, source blocks and config reload (reload is admin-only — see below). |
agent | A node's credential — a scrub node's or an edge node's: the rules and zones polls, its own report and the edge ACME coordination, and nothing else. Bind it to its node with node (below). |
There is no separate admin role to assign — an admin is simply an operator token with no tenant set. The role values the engine accepts are viewer, operator and agent; the admin/scoped distinction is the Multi-tenancy axis, covered below.
iThe agent role is not a lesser viewer
agent sits off the privilege ladder, below viewer: it grants exactly the node routes
(the rules and zones polls, the two self-reports, the edge ACME slot and challenge publication)
and is denied every other endpoint — including the source-block channel, which installs kernel
rules and so stays operator-only — a compromised agent token, which lives on a remote box, must
not become a read-everything key. It also cannot be tenant-scoped: the rule feed is
deployment-wide, so a tenant on an agent token is a hard validation error. Give every node its
own agent token, never an operator one, and bind it to the node (below).
Binding an agent token to its node
An agent token carries node: the name of exactly one edge.nodes[] or scrubbing.nodes[] entry.
The brain then refuses that token on every route where a node names itself — the polls
(?node=<name>), the self-reports, the ACME slot and challenge publication — when the name presented
is another node's, and it refuses before doing anything with the name: no presence is stamped,
no report stored, no slot granted, no key authorization published. The refusal is a uniform 403
that does not name the bound node, one warning a minute per token in the brain's log, and a count
in kapkan_api_node_binding_refused_total{route}. No audit row is written: the
audit trail attributes what an operator changed, and a refused machine changed nothing — the log
line and the counter are where a misconfigured or stolen node token shows up. A bound token that
polls without ?node= is refused as well: its binding says which node it is.
api:
tokens:
- { name: edge-fra1, token_env: KAPKAN_EDGE_FRA1, role: agent, node: edge-fra1 }
- { name: edge-nyc1, token_env: KAPKAN_EDGE_NYC1, role: agent, node: edge-nyc1 }
- { name: scrub-1, token_env: KAPKAN_SCRUB_1, role: agent, node: fra1 }
Rules: node is allowed on agent tokens only; the name must exist in exactly one of the two node
lists (a name present in both is refused as ambiguous — the two channels are different trust
domains); several tokens may bind the same node, which is how a token is rotated without a gap.
Without node an agent token is a fleet-wide credential: it may poll as any node and report as
any node — the reason a stolen token used to mean rotating it for the whole fleet. What node
changes is impersonation and the rotation radius; what limits the set of zones a stolen bound token
can issue for is the node's placement scope: acting as its
own node it can take the issuance slot and publish an ACME key authorization only for the zones
that scope covers — every zone on a fleet without scopes. Treat a node compromise as a certificate
exposure for its node's zones, and rotate that node's token. An unbound token keeps working
exactly as before, so a fleet migrates one node at a time — unless any node has a
placement scope (edge.nodes[].hostgroups): then every
agent token must carry node, a hard validation error, because a scope enforced against a
shared token is a promise nothing keeps — and it is named until it is bound: by
kapkan -check-config (a WARNING), in the daemon's log at start and on every reload, and in the
edge inventory (unbound_agent_tokens; per node, tokens lists the bound token names and
last_token the token that last polled as it — watch it flip during a migration). Making an unbound
agent token an error on unscoped fleets too is scheduled for a MAJOR release.
Presence is stamped only by agent tokens: an operator polling with ?node=X receives X's document
as a preview and does not make X look alive.
Give a migration step time to show. A token change reaches a poll that is already parked: the
reload wakes it at once and ends it with the answer a fresh poll would now get (401 for a token
that is gone, 403 for one whose binding moved), and the node reconnects with whatever it now
has. The inventory is one window behind, because a sighting is stamped when a poll starts and
again when it ends — that refusal included — so a node you have just cut off reads alive for
edge.stale_after_seconds after the change, about 15 s on the defaults, and you see the flip at
your next read of the inventory. last_token needs no such wait: it is the token of the node's last accepted poll, so it flips at
the node's first poll with the new token — and never for a token the brain refuses. The hold bounds something else: a perfectly
healthy parked node's last_seen is up to a hold (25 s) old, which is normal — holding is what
says its poll is open.
token_env and tokens are mutually exclusive — token_env is exactly one operator
token. A request is matched against every configured token (in constant time); the highest
matching role applies. A read with a viewer token works; a mutation with a viewer token is
refused with 403 Forbidden; a request with no valid token is 401 Unauthorized. An
empty or unset env var never matches, so a misconfigured secret fails closed.
Give every token a distinct secret — if one secret value is configured for two tokens with
different roles, tenants or node bindings (an unbound entry and a bound one included), the
request is refused (401 Unauthorized) rather than guessing which one you meant. The daemon logs
ambiguous API token when this happens.
A token's secret value is read from the environment on every request, so rotating it takes effect on the next request. Adding or removing token entries in the YAML changes the token set, which takes effect on the next config reload. Neither needs a restart.
A token may also carry an optional tenant, scoping it to one customer's data. That is the
Multi-tenancy feature, layered on the roles described here.
Manual ban/unban only require an operator token, but POST /api/v1/config/reload is
additionally admin-only: the token must be unscoped (carry no
tenant), because a reload rewrites every tenant's policy and the token set itself. A
tenant-scoped operator token is refused with 403 Forbidden — config reload is restricted to unscoped (admin) tokens.
iThe token name is the operator identity
Each token's name is what the audit log records as the operator for every
ban, unban and config reload it issues — so give tokens meaningful names. On an open
(token-less) API there is no principal to attribute, and the audit operator is empty.
What it protects
With a token set, every /api/v1 request must carry it in an Authorization header using
the bearer scheme:
GET /api/v1/status HTTP/1.1
Host: kapkan.internal:8080
Authorization: Bearer <token>
The supplied token is compared to the configured one in constant time, so the comparison itself does not leak the secret through timing.
What stays open and what becomes protected:
| Path | With auth configured |
|---|---|
GET /api/v1/* | Requires a viewer (or operator) token |
POST /api/v1/* | Requires an operator token and a JSON content type |
GET /metrics | Open — Prometheus scrape stays unauthenticated |
Static UI shell (/) | Open — but the data behind it is not |
The dashboard's static UI shell remains reachable so a browser can load it, but it has no
data of its own: it polls /api/v1, so it prompts for the token and keeps it in
sessionStorage. /metrics likewise stays open for your Prometheus scraper. See the
dashboard page for how the UI handles the token prompt.
CSRF
POST endpoints require Content-Type: application/json in addition to the token. A browser
performing a cross-site request cannot set a custom Authorization header without a CORS
preflight, and a simple form post cannot send the JSON content type. Requiring both — the
token in a request header and the JSON content type — blocks cross-site request forgery
against the manual ban, unban, and config-reload endpoints.
iRoles and tenants are different axes
A role scopes what a token may do (read vs. mutate). A token's optional tenant scopes
which data it may see and touch. They compose: a viewer can be unscoped (all tenants) or
scoped to one. See Multi-tenancy.
Related
- Multi-tenancy — scope a token to one customer's data.
- Audit log — token names become the operator identity on every mutation.
- REST API — the endpoints the token protects.
- Dashboard — how the embedded UI prompts for and stores the token.
- Deployment — binding the listener and supplying the token in production.