Skip to content

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:

  1. 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.0 on a public interface unless that interface is firewalled off.

  2. HIVEMIND_FORWARD_AUTH_TRUSTED_NETS is 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 typically 127.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 as 0.0.0.0/0.

If Hivemind runs in a container, 127.0.0.1/32 cannot 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 as 127.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 on 127.0.0.1 does 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/32 entry 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.

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

  1. Provider — Applications → Providers → Create → Proxy Provider
  2. Name: Hivemind
  3. Authentication flow: default-authentication-flow
  4. Authorization flow: default-provider-authorization-implicit-consent (or explicit if you want a "do you authorize?" page on each login)
  5. Mode: Forward auth (single application)
  6. External host: https://hivemind.your-domain.com
  7. Token validity: 24 hours (or whatever your policy is)

  8. Application — Applications → Applications → Create

  9. Name: Hivemind
  10. Slug: hivemind
  11. Provider: the proxy provider above

  12. Outpost — Applications → Outposts → embedded-outpost

  13. Add the new provider to its providers list. Save. Wait ~5 seconds for the outpost to reload.

  14. Groups — Create the groups you will map to roles (or reuse existing). The names below are Hivemind's defaults for the first two:

  15. hivemind-admin — full admin. Membership here is the only way an SSO identity reaches Admin.
  16. hivemind-user — regular user: may read and write.
  17. 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.
  18. 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_header is not a forward_auth subdirective, and Caddy refuses to load a config that puts it there. At the top level, Caddy runs it before any handle block. So it also strips the headers on the bypass routes (health, public, API key), where no forward_auth would 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_auth sits inside the catch-all handle, not at the top level. Caddy runs a top-level forward_auth before every handle block, 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.

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:

INFO  forward-auth enabled trusted_nets=[127.0.0.1/32] user_header=X-Authentik-Username ...

If you see this warning instead, the trusted-net list is empty:

WARN  HIVEMIND_FORWARD_AUTH=true but HIVEMIND_FORWARD_AUTH_TRUSTED_NETS 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:

  1. If any of the user's groups equals (case-insensitive) HIVEMIND_GROUP_ADMIN → Admin
  2. Else if any equals HIVEMIND_GROUP_USER → User
  3. Else if any equals HIVEMIND_GROUP_READONLY → ReadOnly
  4. 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-hivemind in forward-auth mode, external host https://hivemind.northwind.example
  • Application northwind-hivemind linked to the provider
  • Provider added to the embedded outpost
  • Groups hivemind-admin (operators) and hivemind-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:

  1. Bring up the server. Visit /auth/login — the page shows but you have no account yet.
  2. POST /auth/register with {username, email, password}. The first registration creates an admin account. Subsequent calls return 409.
  3. 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:

  1. 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.
  2. Give the caller its own address on the proxy's network and add that /32.
  3. 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 the observed_source field nor the first WARN above exists, and this section cannot be followed as written. Check for the code field 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:

curl -v -H "Host: hivemind.your-domain.com" http://127.0.0.1:8585/api/v1/me

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:

  1. The user is actually in the admin group on the IdP side (Authentik Identity → Users → click user → Groups tab).
  2. The outpost is reloaded after group membership changes (Authentik does not propagate group changes to live sessions; user must re-login).
  3. The delimiter matches: Authentik defaults to |. Override with HIVEMIND_FORWARD_AUTH_GROUPS_DELIM=, or ; if your IdP differs.
  4. Group name comparison is case-insensitive but otherwise exact — Hivemind-Admin and hivemind-admin both match HIVEMIND_GROUP_ADMIN=hivemind-admin, but hivemind-admins does 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).