GitHub

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.

RoleMay do
viewerRead only: status, attacks, hosts, bans, metrics.
operatorEverything a viewer can, plus manual ban/unban, source blocks and config reload (reload is admin-only — see below).
agentA 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 exclusivetoken_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 Forbiddenconfig 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:

PathWith auth configured
GET /api/v1/*Requires a viewer (or operator) token
POST /api/v1/*Requires an operator token and a JSON content type
GET /metricsOpen — 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.

  • 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.