SSO setup¶
Hivemind supports two authentication modes for human (browser) users. They are mutually exclusive at the UI layer:
| Mode | Toggle | Who owns login? | Sessions |
|---|---|---|---|
| Local accounts (default) | HIVEMIND_FORWARD_AUTH=false |
Hivemind | hm_session cookie, Argon2id-hashed passwords, in-memory store |
| Forward auth | HIVEMIND_FORWARD_AUTH=true |
Your IdP (Authentik, Keycloak, ...) via your reverse proxy | The proxy / IdP |
The X-API-Key path (X-API-Key: hm-...) is unconditional — it works in
either mode and is what daemons, the TUI, and MCP clients use.
This document covers the forward-auth path. If you're staying on local
accounts, the only thing you need is POST /auth/register once — and that is
also the ceiling: it is allowed only while the users table has no local account,
the second call returns 409, and there is no admin route or UI that creates a
second local user (the users API exposes only GET /me). Local accounts cap at
exactly one human. If more than one person will ever sign in, forward auth is not
a preference, it is the mechanism that makes it possible — set it up before you
register that first account.
Threat model (read this first)¶
Forward auth is the cleanest way to bolt SSO onto a service that doesn't
implement OIDC natively. The trade-off: the headers carrying the
authenticated identity are not signed. Anyone who can reach the server's
TCP port can set X-Authentik-Username: admin and ask for admin access.
The single thing standing between that attacker and your data is the
trusted-upstream check: Hivemind only honors forward-auth headers from
peer IPs that match a configured CIDR (HIVEMIND_FORWARD_AUTH_TRUSTED_NETS).
A direct TCP connection to the server from anywhere else is rejected with
403 Forbidden, even if the request carries the right headers.
This means three things must all be true for the SSO path to be safe:
-
The server's TCP port is not reachable from the public internet. Bind the listener to loopback, a private VLAN, or a Tailnet — never
0.0.0.0on a public interface unless that interface is firewalled off. -
HIVEMIND_FORWARD_AUTH_TRUSTED_NETSis set to the smallest CIDR that includes the reverse proxy and excludes everything else. For a co-located Caddy running as a host process, that's typically127.0.0.1/32. For a containerised proxy, it is the proxy's own address on the shared docker network — ideally a/32, not the whole bridge subnet. Never leave it as0.0.0.0/0.
If Hivemind runs in a container,
127.0.0.1/32cannot match anything. Loopback is namespace-local: an address in the container's own network namespace. A caller outside that namespace — including a process on the docker host connecting to a published port — never arrives as127.0.0.1. It arrives as the container's view of that peer, which for anything originating on the host is the bridge gateway (docker network inspect <net> -f '{{range .IPAM.Config}}{{.Gateway}}{{end}}'). Publishing the port on127.0.0.1does not change this, and neither does disabling the userland proxy: the host's own address on that bridge is the gateway.A
127.0.0.1/32entry in a containerised deployment is therefore inert. It reads as granting trust and grants none — and it is latent, because it would match if the same config were later moved to host networking. Hivemind warns about this at startup; see Troubleshooting.
- The reverse proxy strips client-supplied
X-Authentik-*headers before forwarding the request. Hivemind does not strip incoming headers on its own — that would be a defense-in-depth measure but it is not what stops the attack; the trusted-upstream check is. Your proxy is the right place to enforce header hygiene.
If any of those is false, an attacker can spoof identities. The Hivemind side fails closed (403) whenever it can; the rest is the operator's responsibility.
Authentik provider config¶
Hivemind uses Authentik's Proxy Provider in forward-auth (single application) mode.
- Provider —
Applications → Providers → Create → Proxy Provider - Name:
Hivemind - Authentication flow:
default-authentication-flow - Authorization flow:
default-provider-authorization-implicit-consent(or explicit if you want a "do you authorize?" page on each login) - Mode:
Forward auth (single application) - External host:
https://hivemind.your-domain.com -
Token validity: 24 hours (or whatever your policy is)
-
Application —
Applications → Applications → Create - Name:
Hivemind - Slug:
hivemind -
Provider: the proxy provider above
-
Outpost —
Applications → Outposts → embedded-outpost -
Add the new provider to its
providerslist. Save. Wait ~5 seconds for the outpost to reload. -
Groups — Create the groups you will map to roles (or reuse existing). The names below are Hivemind's defaults for the first two:
hivemind-admin— full admin. Membership here is the only way an SSO identity reaches Admin.hivemind-user— regular user: may read and write.hivemind-readonly— only if you need a read-only tier. Create this one whenever anyone on the instance should be able to look without writing; see Which of the three to set before you skip it.- Add the appropriate users to each group. Group membership is what drives Hivemind's RBAC; nothing else does.
Caddy forward_auth directive¶
This is the canonical Caddyfile snippet for putting Authentik in front of
Hivemind. It lives on the same host as Hivemind (so the upstream peer IP
is 127.0.0.1).
hivemind.your-domain.com {
# Header hygiene, for EVERY request: strip any client-supplied
# identity headers first. Caddy runs a site-level request_header
# before any handle block, so this covers the bypass routes below
# as well as the forward_auth path. On the SSO path, forward_auth
# then sets these headers from Authentik's own answer.
request_header -X-Authentik-Username
request_header -X-Authentik-Email
request_header -X-Authentik-Groups
request_header -X-Authentik-Uid
request_header -X-Authentik-Name
# Browser auto-fetches that must NOT start an OAuth flow.
# /favicon.ico in particular races / on the outpost session cookie
# and the real callback 400s with "invalid state" without this.
@browser_autofetch path /favicon.ico /robots.txt /apple-touch-icon.png /apple-touch-icon-precomposed.png
handle @browser_autofetch {
respond 204
}
# Public API surface — visitor chat + health.
# Public agents are called at /api/v1/public/agents/chat/<slug>
# server-to-server. Tier-1 agent profiles enforce their own IP rate
# limits + cost caps server-side, so this can safely skip Authentik.
handle /api/v1/public/* {
reverse_proxy 127.0.0.1:8585
}
handle /api/v1/health {
reverse_proxy 127.0.0.1:8585
}
# API-key bypass: any /api/v1/* request carrying X-API-Key skips
# forward_auth and goes straight to Hivemind, which validates the
# key against `users.api_key` (auth/api_key.rs). Invalid keys get
# 401 from Hivemind. Use `header_regexp` because Caddy 2 rejects
# `header X-API-Key *` and bare `header X-API-Key` as parser errors
# ("malformed header matcher: expected both field and value").
@api_with_key {
path /api/v1/*
header_regexp X-API-Key .+
}
handle @api_with_key {
reverse_proxy 127.0.0.1:8585
}
# Everything else: the web UI and any request not matched above.
# forward_auth goes INSIDE this catch-all handle, never at the top
# level of the site block. Caddy runs a top-level forward_auth
# before every handle block, so the bypasses above would never
# bypass it.
handle {
# Authentik's forward-auth endpoint (replace the URL with your
# Authentik).
forward_auth http://127.0.0.1:9000 {
uri /outpost.goauthentik.io/auth/caddy
copy_headers X-Authentik-Username X-Authentik-Email X-Authentik-Groups X-Authentik-Uid X-Authentik-Name
# Authentik 5xx → fail closed (the request errors instead of
# falling through unauthenticated).
@goauthentik_redirect status 401 302
handle_response @goauthentik_redirect {
redir * @goauthentik_redirect.header.Location 302
}
}
reverse_proxy 127.0.0.1:8585
}
}
The request_header -X-Authentik-* lines are what enforce header hygiene.
Without them, a client who knows the headers exist can set them and have
Authentik's outpost accept the request as already-authenticated. Hivemind's
trusted-upstream check would still pass (Caddy IS at 127.0.0.1) and the
attacker would be in.
Two placement rules make this snippet work, and both are easy to get wrong:
- The strip lines sit at the top level of the site block, not inside
forward_auth.request_headeris not aforward_authsubdirective, and Caddy refuses to load a config that puts it there. At the top level, Caddy runs it before anyhandleblock. So it also strips the headers on the bypass routes (health, public, API key), where noforward_authwould ever replace them. Without that, a request on a bypass route could carry a forged identity header straight to Hivemind from a trusted peer. forward_authsits inside the catch-allhandle, not at the top level. Caddy runs a top-levelforward_authbefore everyhandleblock, so the bypass routes would never bypass it.
API-key bypass — security model¶
Adding the @api_with_key block moves the auth-fail surface for API-key
requests from Authentik down to Hivemind. The validation happens in
auth/api_key.rs — invalid keys return 401 with a JSON body, valid keys
proceed. Two things to keep in mind:
- API keys must be high-entropy. Hivemind's CLI generates them as
64-char
hm-*random strings. The bypass is safe because brute-forcing that keyspace is infeasible; if you ever provision a short/guessable key the bypass becomes a real exposure. - Rate-limiting belongs in front of the bypass. Hivemind's API-key
middleware does NOT rate-limit invalid attempts on its own. If you
expose
/api/v1/*to the public internet, run a CrowdSec bouncer in front of Caddy and enable a 401-bf scenario (e.g.LePresidente/http-generic-401-bf). The seglamater.app deployment uses this stack and verified the ban-on-flood path on 2026-05-09. Without rate-limiting, a flood of bogus keys can burn DB connections and fill audit logs even though it can't actually break the auth.
Commander link bypass¶
If you use the Commander link, the workstation's
spawn hive-ask tick calls /api/v1/commander-link/* with no SSO session.
Behind forward auth those requests get a 302 to your login page, and the
tick refuses to follow it, so no ask ever reaches the Commander. Route that
prefix to Hivemind outside forward_auth and strip the identity headers on
it. Hivemind verifies every request's Ed25519 signature itself. The Caddy
and nginx snippets, and why the bypass is safe, are in
Commander link: Behind SSO forward auth.
Hivemind environment¶
HIVEMIND_FORWARD_AUTH=true
HIVEMIND_FORWARD_AUTH_TRUSTED_NETS=127.0.0.1/32
HIVEMIND_FORWARD_AUTH_USER_HEADER=X-Authentik-Username
HIVEMIND_FORWARD_AUTH_EMAIL_HEADER=X-Authentik-Email
HIVEMIND_FORWARD_AUTH_GROUPS_HEADER=X-Authentik-Groups
HIVEMIND_FORWARD_AUTH_GROUPS_DELIM=|
HIVEMIND_GROUP_ADMIN=hivemind-admin
HIVEMIND_GROUP_USER=hivemind-user
HIVEMIND_GROUP_READONLY=hivemind-readonly
HIVEMIND_GROUP_READONLY is optional and unset by default — but leaving it
unset means no SSO identity can ever reach the ReadOnly role. See
Which of the three to set before deciding.
Restart hivemind-server. On boot you should see:
If you see this warning instead, the trusted-net list is empty:
Set the variable and restart.
Group → role resolution¶
Hivemind has three roles:
| Role | Source |
|---|---|
| Admin | Member of HIVEMIND_GROUP_ADMIN |
| User | Member of HIVEMIND_GROUP_USER (or no group match — see below) |
| ReadOnly | Member of HIVEMIND_GROUP_READONLY (optional — HIVE-1025) |
Resolution rules, checked in this order — more-privileged match wins:
- If any of the user's groups equals (case-insensitive)
HIVEMIND_GROUP_ADMIN→ Admin - Else if any equals
HIVEMIND_GROUP_USER→ User - Else if any equals
HIVEMIND_GROUP_READONLY→ ReadOnly - Else → User (default-allow)
Every rule checks the caller's whole group list, so membership in more than
one mapped group is decided by privilege, not by which group the IdP happened
to list first: a user in both HIVEMIND_GROUP_ADMIN and
HIVEMIND_GROUP_READONLY resolves Admin; a user in both
HIVEMIND_GROUP_USER and HIVEMIND_GROUP_READONLY resolves User.
Default-allow (rule 4) exists because BYO-IdP customers will not always
curate group membership perfectly, and "no groups → can't log in" creates
support load out of proportion to the actual security gain (Authentik
already authenticated the user; the question is just authorization level).
Leaving HIVEMIND_GROUP_READONLY unset does not change this: no group name
is ever empty, so an unset HIVEMIND_GROUP_READONLY can never match and
unmapped users still land on User, exactly as before this variable
existed.
Setting HIVEMIND_GROUP_USER to a sentinel group nobody belongs to does
not demote anyone to ReadOnly — it only routes those users to the rule-4
default (User), the most permissive outcome, not the most restrictive.
To actually grant ReadOnly, put the intended people in a real
HIVEMIND_GROUP_READONLY group.
Which of the three to set¶
| Variable | Default | Set it? |
|---|---|---|
HIVEMIND_GROUP_ADMIN |
hivemind-admin |
Yes. Point it at a group you actually control. It is the only route to Admin, so whoever can add members to that group can grant administrator. |
HIVEMIND_GROUP_USER |
hivemind-user |
Yes, for clarity. It does not gate anything on its own — rule 4 already lands unmapped identities on User — but naming the group your staff belong to makes the mapping explicit rather than incidental. |
HIVEMIND_GROUP_READONLY |
(unset) | Set it if anyone should be able to read without writing. Unset, it is inert and there is no read-only tier reachable from SSO at all. |
The consequence worth reading twice, for a multi-person instance. Rule 4 is
default-allow: an authenticated identity that matches none of your mapped groups
resolves to User, which can write. A new colleague added to your IdP but to
none of these groups can therefore write on day one. That is deliberate — see the
paragraph above on why denying unmapped logins was rejected — but it means the
read-only tier is not a default you inherit. If your instance has people who
should only observe — auditors, contractors, staff outside the operating team —
HIVEMIND_GROUP_READONLY is the only thing that gives them that role, and it does
nothing until you both set it and put them in the group.
Setting none of the three leaves an instance behaving exactly as it did before
these variables existed: the two defaults apply and readonly_group stays empty
and unmatched.
Before you decide who gets which role, read
Role capabilities. It derives, from the code, exactly
what each of Admin / User / ReadOnly can and cannot do. The short version worth
knowing up front: on the HTTP admin surface User and ReadOnly are treated
identically — both are simply "not admin" — so choosing ReadOnly buys nothing
there. Where it does bite is content: a read-only caller cannot create,
update or delete a memory, cannot ingest or delete a knowledge or vault
document, and cannot nudge or hand off to a Commander. Choose ReadOnly for
someone who should not change recorded knowledge, not as a way to keep someone
away from administration — User was already kept away from that.
Worked example: Northwind Logistics¶
Northwind Logistics — a fictional customer used throughout this example — fronts their Hivemind with Authentik via Caddy on their VPS. The relevant pieces (no real credentials shown):
Authentik (auth.northwind.example):
- Proxy provider
northwind-hivemindin forward-auth mode, external hosthttps://hivemind.northwind.example - Application
northwind-hivemindlinked to the provider - Provider added to the embedded outpost
- Groups
hivemind-admin(operators) andhivemind-user(everyone else)
Caddyfile on Northwind's VPS:
hivemind.northwind.example {
request_header -X-Authentik-Username
request_header -X-Authentik-Email
request_header -X-Authentik-Groups
handle {
forward_auth http://127.0.0.1:9000 {
uri /outpost.goauthentik.io/auth/caddy
copy_headers X-Authentik-Username X-Authentik-Email X-Authentik-Groups
}
reverse_proxy 127.0.0.1:8585
}
}
.env on Northwind's Hivemind host:
HIVEMIND_FORWARD_AUTH=true
HIVEMIND_FORWARD_AUTH_TRUSTED_NETS=127.0.0.1/32
HIVEMIND_GROUP_ADMIN=hivemind-admin
HIVEMIND_GROUP_USER=hivemind-user
That's the whole setup. Northwind operators in hivemind-admin can manage the
service through the web UI; everyone else gets the User role.
Daemons and the TUI continue to use X-API-Key against the same backend — but
they must reach it through Caddy, via the @api_with_key block above. With
HIVEMIND_FORWARD_AUTH=true the trusted-upstream check applies to API-key
requests too, and it runs before the key is evaluated: a client that talks
directly to hivemind-server's port from outside the trusted net gets a 403
that looks nothing like an auth error. See
An API-key client gets 403 while the web UI works.
Customers without an Authentik¶
Don't set HIVEMIND_FORWARD_AUTH. Hivemind will serve its own login page
and store password hashes locally with Argon2id. The bootstrap flow is:
- Bring up the server. Visit
/auth/login— the page shows but you have no account yet. POST /auth/registerwith{username, email, password}. The first registration creates an admin account. Subsequent calls return 409.- Log in.
Set-Cookie: hm_session=hms-...is issued. Sessions live for 7 days and are stored in-process (they evaporate on server restart, by design — operators just re-log-in).
To migrate to forward-auth later, set HIVEMIND_FORWARD_AUTH=true and the
matching env vars. Local accounts continue to exist in the DB but the login
endpoint short-circuits to the IdP. API keys remain valid throughout.
Troubleshooting¶
"403 Forbidden" on every request¶
The trusted-upstream check is rejecting the source. The refusal tells you which address it saw — you do not need log access to find out:
$ curl -s -X POST http://127.0.0.1:8585/api/v1/tasks
{"error":{"status":403,
"code":"forward_auth_untrusted_source",
"message":"request source is not in HIVEMIND_FORWARD_AUTH_TRUSTED_NETS (observed source: 192.168.0.1)",
"observed_source":"192.168.0.1"}}
observed_source is the peer address as Hivemind sees it, which is not
always the address you connected from — see the namespace note in
Threat model above. Compare it against
HIVEMIND_FORWARD_AUTH_TRUSTED_NETS.
The same event is always logged server-side, with what was required alongside what was observed:
WARN refusing a request from a source outside HIVEMIND_FORWARD_AUTH_TRUSTED_NETS
peer=192.168.0.1 code=forward_auth_untrusted_source trusted_nets=[172.18.0.2/32]
If the request carried X-Authentik-* headers, the log says so explicitly
instead — that is a bypass attempt, not a misconfiguration:
WARN rejecting forward-auth headers from untrusted source — possible header-injection attempt
peer=10.4.5.6 code=forward_auth_untrusted_source trusted_nets=[172.18.0.2/32]
Then decide, and prefer the first option:
- Route the caller through your reverse proxy, which is already trusted. For an API-key client this needs no trust-boundary change at all — the proxy forwards it, Hivemind sees the proxy's address, and the key is validated normally. This is the intended path for keyed integrations.
- Give the caller its own address on the proxy's network and add that
/32. - Widen the CIDR only as a last resort, and never to a whole bridge subnet: that trusts every container and host process on it to assert an identity.
If the peer is something you did not expect, that is the actual attack — investigate before "fixing" it by relaxing the CIDR.
On builds predating HIVE-9bd7090e the un-headered case — an API-key client, which sends no
X-Authentik-*— was refused with no log line at any verbosity and a body that named only the rule. On such a build neither theobserved_sourcefield nor the first WARN above exists, and this section cannot be followed as written. Check for thecodefield in the 403 body: if it is absent, you are on an older build.
An API-key client gets 403 while the web UI works¶
Expected, and it is a position refusal, not an auth failure — the same 403 appears with a deliberately invalid key, because the trusted-net check runs before the key is evaluated.
When HIVEMIND_FORWARD_AUTH=true, the trusted-upstream check applies to every
request on the authenticated routes, including X-API-Key ones. Browser traffic
works because it arrives via the reverse proxy, which is trusted; a client
talking directly to the server's port is not. Route the client through the proxy
(option 1 above).
Headers missing from request¶
Caddy isn't copying them through. Verify with:
The expected headers should be visible in the request Caddy forwards. If
not, check copy_headers in the forward_auth block.
Group mismatch — user gets User when they should be Admin¶
The IdP isn't reporting the group the way Hivemind expects. Confirm:
- The user is actually in the admin group on the IdP side (Authentik Identity → Users → click user → Groups tab).
- The outpost is reloaded after group membership changes (Authentik does not propagate group changes to live sessions; user must re-login).
- The delimiter matches: Authentik defaults to
|. Override withHIVEMIND_FORWARD_AUTH_GROUPS_DELIM=,or;if your IdP differs. - Group name comparison is case-insensitive but otherwise exact —
Hivemind-Adminandhivemind-adminboth matchHIVEMIND_GROUP_ADMIN=hivemind-admin, buthivemind-adminsdoes NOT.
Looped redirects (Authentik → Hivemind → Authentik → ...)¶
Caddy is forwarding the Authentik callback through the forward_auth
block, which then re-authenticates the callback request. Make sure the
/outpost.goauthentik.io/* path is excluded from the forward-auth
block (Caddy handles this automatically when the upstream Authentik path
matches its own URL prefix; if it doesn't, add an explicit handle for
that path that bypasses forward_auth).
"401 Unauthorized" with valid forward-auth — but only on /api/v1¶
This is the API endpoints that wear the api-key middleware. The order of middlewares in Hivemind is forward-auth → session → api-key, with each one short-circuiting if a User is already attached. If forward-auth ran successfully a User is attached, so api-key passes through. If you're still seeing 401 here, the trusted-upstream check failed (see above) and forward-auth never attached a user.
Startup WARN: "HIVEMIND_FORWARD_AUTH_TRUSTED_NETS contains a loopback entry"¶
You'll see this even with a correct config that follows this
guide's own 127.0.0.1/32 recommendation above — the check fires on
any loopback entry unconditionally, with no way to tell a host-process
Caddy from a containerized one. The warning is still worth reading:
it's telling you loopback is network-namespace-local, so the entry
only matches callers inside hivemind-server's own namespace. If your
proxy runs as a host process talking to a published port, that's
exactly the caller 127.0.0.1/32 is meant to match, and this WARN is a
false positive you can ignore. If hivemind-server itself runs in a
container, this entry matches nothing — a caller on the docker
host arrives as the bridge gateway, not 127.0.0.1, even when the port
is published on 127.0.0.1 — and you need the gateway address instead
(docker network inspect <net> -f '{{range .IPAM.Config}}{{.Gateway}}{{end}}',
same command as the blockquote earlier in this guide).
What Hivemind does NOT do¶
- It does not strip incoming
X-Authentik-*headers. That's the proxy's job. The trusted-upstream check is what stops the attack Hivemind cares about. - It does not validate any signature on the headers. Authentik's proxy mode does not sign forward-auth headers; the trust comes from the network path.
- It does not auto-discover OIDC. This is forward-auth, not OIDC. When/if Hivemind gains a native OIDC client, it'll be a separate path (different env vars, different middleware).