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 (allowed only while the users table has no local accounts yet); after that the admin-only user-management UI takes over.


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, that's typically 127.0.0.1/32. For a containerised proxy, that's the docker bridge subnet. Never leave it as 0.0.0.0/0.

  3. 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. ProviderApplications → 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. ApplicationApplications → Applications → Create

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

  12. OutpostApplications → Outposts → embedded-outpost

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

  14. Groups — Create two groups (or reuse existing):

  15. hivemind-admin — full admin (members can manage agents, users, settings)
  16. hivemind-user — regular user
  17. 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 {
    # 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.
    # `segchat`-style public agents call /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
    }

    # Authentik's forward-auth endpoint for the web UI + everything
    # else (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

        # Crucially: strip any client-supplied headers BEFORE the request
        # reaches the Authentik outpost. This is what stops X-Authentik-*
        # header injection from outside.
        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

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

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.

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

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 Reserved (no current automatic mapping)

Resolution rules (single-pass, first match wins):

  1. If any of the user's groups equals (case-insensitive) HIVEMIND_GROUP_ADMINAdmin
  2. Else if any equals HIVEMIND_GROUP_USERUser
  3. Else → User (default-allow)

Default-allow 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).

If you want strict mapping, set HIVEMIND_GROUP_USER to a sentinel group that nobody actually belongs to. We'll add explicit ReadOnly mapping when the product needs it.


Worked example: AIWF

AIWF (the first paying customer) fronts their Hivemind with Authentik via Caddy on their VPS. The relevant pieces (no real credentials shown):

Authentik (auth.aiwf.example):

  • Proxy provider aiwf-hivemind in forward-auth mode, external host https://hivemind.aiwf.example
  • Application aiwf-hivemind linked to the provider
  • Provider added to the embedded outpost
  • Groups hivemind-admin (operators) and hivemind-user (everyone else)

Caddyfile on AIWF's VPS:

hivemind.aiwf.example {
    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
        request_header -X-Authentik-Username
        request_header -X-Authentik-Email
        request_header -X-Authentik-Groups
    }
    reverse_proxy 127.0.0.1:8585
}

.env on AIWF'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. AIWF 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 regardless.


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

Trusted-upstream check is rejecting the source. Look at the server logs:

WARN rejecting forward-auth headers from untrusted source — possible header-injection attempt peer=10.4.5.6

Check the logged peer IP against HIVEMIND_FORWARD_AUTH_TRUSTED_NETS. If the peer is your reverse proxy, widen the CIDR to include it. If the peer is something unexpected, that's the actual attack — investigate before "fixing" by relaxing the CIDR.

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.


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