Authentication
Gatehouse supports three authentication methods. They all produce (or are) a bearer token that goes in the Authorization header.
Root token#
Set via the GATEHOUSE_ROOT_TOKEN environment variable on the container. The root token has admin capability on everything. Use it to bootstrap the first admin user, then unset the env var and restart.
curl -H "Authorization: Bearer $GATEHOUSE_ROOT_TOKEN" \
http://localhost:3100/v1/auth/users
User accounts (for humans)#
Admin users log into the web UI with a username and password. User accounts can optionally enable TOTP two-factor auth.
Login:
curl -X POST http://localhost:3100/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "alice", "password": "..."}'
Response (when TOTP is disabled):
{
"token": "<JWT>",
"identity": "user:alice",
"expires_in": 86400
}
When TOTP is enabled, the login endpoint returns a short-lived pre-auth token that can only be exchanged for a full JWT at POST /v1/auth/login/totp with a valid 6-digit code or recovery code.
AppRoles (for agents)#
AppRoles are how agents authenticate. An admin creates an AppRole in the web UI or via the API, which produces a role_id and secret_id. The agent exchanges these for a JWT and uses the JWT for all subsequent requests.
Admin creates an AppRole:
curl -X POST http://localhost:3100/v1/auth/approle \
-H "Authorization: Bearer $ADMIN_JWT" \
-H "Content-Type: application/json" \
-d '{"display_name": "my-agent", "policies": ["agent-readonly"]}'
Response:
{
"role_id": "role-xxxx-...",
"secret_id": "yyyy-...",
"display_name": "my-agent",
"policies": ["agent-readonly"],
"warning": "Save the secret_id now, it cannot be retrieved later"
}
Agent exchanges credentials for a JWT:
curl -X POST http://localhost:3100/v1/auth/approle/login \
-H "Content-Type: application/json" \
-d '{"role_id": "role-xxxx-...", "secret_id": "yyyy-..."}'
Response:
{
"token": "<JWT>",
"identity": "approle:my-agent",
"policies": ["agent-readonly"],
"expires_in": 86400
}
Onboarding links (no secrets in chat)#
Pasting role_id / secret_id into a chat window puts them into conversation history, logs, and anywhere the transcript gets shipped. Onboarding links solve that. The operator generates a one-time URL, hands it to the agent over any channel, and the agent fetches credentials directly.
Operator side: generate the link. In the web UI, open the AppRoles tab, click Onboard on the target row, pick a TTL (5 min to 1 hour), and copy the generated prompt. It’s a short curl snippet that writes the bootstrap markdown to a temp file and cats it back.
Or via the API:
curl -X POST http://localhost:3100/v1/onboard \
-H "Authorization: Bearer $ADMIN_JWT" \
-H "Content-Type: application/json" \
-d '{"role_id": "role-xxxx-...", "ttl_seconds": 900, "label": "telegram bot"}'
Response:
{
"id": "onboard-...",
"onboard_url": "https://gatehouse.example/v1/onboard/<token>",
"expires_at": "..."
}
Agent side: exchange the token. The agent runs the curl snippet, reads the markdown (which is self-installing instructions), and calls exchange exactly once:
curl -X POST https://gatehouse.example/v1/onboard/<token>/exchange
Response (consumed on success; subsequent calls return 410 Gone):
{
"token": "<JWT>",
"role_id": "role-...",
"secret_id": "...",
"base_url": "https://gatehouse.example",
"mcp_url": "https://gatehouse.example/v1/mcp",
"role_display_name": "my-agent",
"policies": ["agent-readonly"],
"expires_in": 86400
}
The agent writes role_id / secret_id into its harness’s credential path (~/.hermes/.env, ~/.claude/.env.gatehouse, ~/.openclaw/workspace/.env, or .env.gatehouse in cwd for Codex/Cursor/Windsurf) and installs a gatehouse skill so future sessions know how to use the vault.
Important behaviors:
- Exchange rotates the AppRole’s
secret_id. Any agent still running with the previous secret must re-onboard. - Links are single-use. After exchange they return
410 Gone. - Before exchange, the bootstrap markdown URL is idempotent: if the agent’s context gets compacted or the install fails mid-way, it can re-fetch the same URL until exchange is called.
- If the AppRole has zero policies, the rendered markdown tells the agent to halt rather than probe, because
gatehouse_listwill be empty and every proxy call will be denied. - Set
GATEHOUSE_PUBLIC_URLwhen behind a reverse proxy so the generated link uses the public hostname instead of the LAN address from theHostheader.
Admin endpoints:
# List active links
curl http://localhost:3100/v1/onboard -H "Authorization: Bearer $ADMIN_JWT"
# Revoke an unused link
curl -X DELETE http://localhost:3100/v1/onboard/<id> \
-H "Authorization: Bearer $ADMIN_JWT"
SSO (OpenID Connect)#
Gatehouse supports OIDC single sign-on for the web UI. The login page shows a Sign in with SSO button when SSO is configured; the username and token tabs continue to work for non-SSO users. Tested against PocketID, Authentik, Keycloak, and Google.
Account model: link-by-verified-email. SSO is not a way to create new accounts. The IdP-asserted email must match an existing Gatehouse user’s email field, and the user must be enabled = 1. Admins pre-create the user (Users tab) and populate email; the next time that person clicks Sign in with SSO, they land in their existing Gatehouse account.
Configure#
- Sign in with the bootstrap root token or an admin user.
- Go to Settings → SSO / OAuth Configuration and toggle Enable SSO on.
- Fill in:
- Issuer URL: the IdP’s base URL (e.g.
https://id.example.com). Gatehouse fetches<issuer>/.well-known/openid-configurationwhen you save and refuses the save if discovery fails. - Client ID / Client Secret: from your IdP’s OIDC client registration.
- Redirect URI:
https://<your-gatehouse-host>/v1/auth/sso/callback. Configure the same string in the IdP’s allowed redirect URIs. - Scopes: defaults to
openid profile email. Theopenidscope is required.
- Issuer URL: the IdP’s base URL (e.g.
- Save. Use the Test SSO link to dry-run the IdP redirect in a new tab without logging out.
- On each user record (Users tab), populate Email to match what the IdP will assert.
email_verified enforcement#
By default, Gatehouse requires the IdP’s userinfo to include email_verified: true. This is the safety net against an IdP that lets users self-attest emails.
Some IdPs (notably PocketID) intentionally omit the email_verified claim because their threat model is “the admin invited you, the email is administrator-controlled.” For those, enable Trust email without email_verified claim in the SSO settings card. The hint text on the toggle spells out the tradeoff. Default-safe; only flip it when you trust your IdP’s email setup.
Bootstrap via environment variables (optional)#
If you’d rather automate first-boot setup, set these on the container:
- GATEHOUSE_OAUTH_ISSUER=https://id.example.com
- GATEHOUSE_OAUTH_CLIENT_ID=gatehouse
- GATEHOUSE_OAUTH_CLIENT_SECRET=...
- GATEHOUSE_OAUTH_REDIRECT_URI=https://gatehouse.example.com/v1/auth/sso/callback
If no settings/sso row exists at boot, Gatehouse seeds one with enabled: true and these values. After boot, the database is the single source of truth and the env vars are ignored - manage SSO from the UI.
Endpoints#
| Method | Path | Description |
|---|---|---|
| GET | /v1/auth/sso/status | Public. Returns {enabled: boolean}. Used by the unauth login page. |
| GET | /v1/auth/sso/start | Public. Mints state + nonce + PKCE verifier, 302s to the IdP authorize URL. |
| GET | /v1/auth/sso/callback | Public. Verifies signature/iss/aud/exp/nonce, cross-checks userinfo sub (OIDC §5.3.2), links by email, mints a Gatehouse JWT, 302s to /#sso=<jwt> so the JWT lands in the URL fragment (not query string, not access logs). |
Audit events#
Each callback path emits exactly one audit event. Sift /v1/audit?action=... or container stdout to debug:
| Event | When |
|---|---|
sso.start | /start route mints a state row and redirects. |
sso.callback.success | All checks passed; JWT minted. |
sso.callback.invalid_state | State row missing, expired (>10 min), or already consumed. |
sso.callback.exchange_failed | Code exchange failed, ID token verification failed, JWKS signature failed, or userinfo fetch failed. The audit row’s metadata.error carries the upstream error string. |
sso.callback.unverified_email | IdP’s email_verified is not true and trust_unverified_email is off. |
sso.callback.no_user_match | No enabled Gatehouse user has the IdP-asserted email. |
sso.callback.email_collision | Two or more Gatehouse users share the IdP-asserted email (admin error). |
The user-visible error page is intentionally vague (“Sign-in failed”, “Login link expired”, “No Gatehouse account is linked to this identity”). The audit log is the source of truth for which path actually fired.
TOTP (two-factor auth)#
Enable TOTP for a user account from the web UI: Me → Security → Enable 2FA. Scan the QR code with any RFC 6238 authenticator (Google Authenticator, Authy, 1Password, Aegis). Confirm the setup with a 6-digit code. You’ll be shown 10 one-time recovery codes. Save them somewhere safe.
After enabling TOTP:
- Every subsequent login requires the 6-digit code after the password.
- If you lose your authenticator, you can log in with one of the recovery codes (each consumable exactly once).
- Admins can force-reset a user’s 2FA from the Users tab.