Security¶
This page covers Minder's security posture — what protections are in place, what is deliberately not yet in place — and how to manage the credentials and secrets a deployment needs. For the login/SSO/JWT model in detail, see Authentication.
A default install is development-grade
Out of the box Minder is functional end-to-end but not hardened for public-internet exposure. The measures below describe what exists today plus the steps to take before a production deployment. Work through the hardening checklist before exposing an instance.
Security posture¶
What's in place¶
- JWT authentication at the API Gateway (HS256), with bcrypt-hashed credentials and Redis-backed rate limiting (60-second window, fail-open — if Redis is unreachable, requests are allowed rather than blocked).
- Authelia SSO / OIDC — enabled and enforcing forward-auth on the gated routers, and a real OIDC identity provider that mints the Minder JWT from a verified Authelia identity. See Authentication.
- Traefik v3 reverse proxy — the single ingress; TLS termination and routing
by Docker labels (
exposedByDefault: false, so nothing is exposed unless explicitly labelled). Not Nginx. - Network isolation — storage backends (PostgreSQL, Redis, Qdrant, Neo4j,
MinIO, RabbitMQ, schema-registry) run internal-only on the Docker network and
publish no host port. Host ports that do exist are loopback-bound
(
127.0.0.1) — see Service access. - IP-whitelist middleware on the admin surfaces routed through Traefik (the dashboard, the message-queue management UI, the graph-database browser).
- Least-privilege Docker access — services that need the Docker Engine API
(bundle enable/disable, container logs) go through a proxy with an explicit
allowlist of paths and methods, never a raw
docker.sockmount. - Least-privilege plugin database role — plugins get their own Postgres
role confined to a
plugin_dataschema, with no access to the platform's tables. See Plugin trust model below for what that does and doesn't cover.
Plugin trust model¶
Plugins come in two kinds, and they are trusted differently:
- Manifest plugins are declarative YAML that supplies parameters only (for example webhook → store-vector). No plugin code runs. The platform executes its own built-in trigger and action for them. See Manifest plugins.
- Module (code) plugins are Python classes (
register()/PluginBase, the default shape in the plugin template). The plugin registry imports them and runs them in-process, in the registry's own Python process. This is arbitrary code: a module plugin has the same reach as the registry process itself (its memory, environment, filesystem and network). It is not sandboxed.
What limits a module plugin today:
- Scoped database credentials. The
databasehandle a plugin receives connects as theminder_pluginsrole. That role can only use its ownplugin_dataschema and has no privilege on the platform's tables. Plugins never get the owner credentials; withoutDB_PLUGIN_PASSWORDthey get no database handle at all. This scopes the handle the platform gives a plugin, not the process it runs in. - Shared Redis and InfluxDB credentials. The
redisandinfluxdbhandles still carry the platform's shared credentials. They are not scoped per plugin yet. - Review before it ships. Catalog plugins arrive as pull requests to the
public plugins repo. They are reviewed,
and CI runs
minder-plugin validateand a contract suite on each one. Submitted marketplace listings go through an admin review workflow before they're publicly visible (first-party listings are pre-approved). See Publishing.
Treat installing a module plugin like installing any Python package into the platform: only install plugins you have reviewed or trust. Stronger isolation for code plugins is being worked on and is not shipped.
What's not in place (don't assume)¶
- Plugin sandboxing. Module plugins run in-process in the plugin registry. The scoped database role limits their database handle, not the process. See Plugin trust model.
- RBAC is partial. A user's
roleis checked on a specific set of admin-only actions (model pull/delete, bundle enable/disable/reconcile, plugin lifecycle and service-registry routes, the marketplace admin endpoints, and admin-only plugin actions). Most other write endpoints still only require a valid JWT, not a particular role — authorization is not yet uniform. Don't build workflows that assume broader per-role enforcement than that. - Full browser SSO needs real DNS + TLS. Over plain
localhostor a bare LAN IP the forward-auth 302 works but end-to-end browser SSO does not.
Secrets¶
Configuration and secrets live in a single root .env — the one source of
truth. bash setup.sh self-heals it: any value left as a CHANGEME placeholder
(or missing) is replaced with a strong random value on install/start, and the
file is chmod-ed to 600. A fresh install therefore already has real secrets,
not placeholders.
The placeholders shipped in .env.example are, for example:
POSTGRES_PASSWORD=CHANGEME_POSTGRES_SECRET_32_CHARS
REDIS_PASSWORD=CHANGEME_REDIS_SECRET_32_CHARS
JWT_SECRET=CHANGEME_JWT_SECRET_MINIMUM_64_CHARS_RECOMMENDED
INFLUXDB_TOKEN=CHANGEME_INFLUXDB_SECRET_40_CHARS
Only set your own value if you want a specific one. A value you supply must have
the same shape as a generated one (lowercase hex, exact length; see the table
below). Otherwise setup.sh treats it as a placeholder and replaces it. The one
exception is the Authelia admin password, which may be any value you choose. Authelia's admin password follows the
same self-heal: it's generated per deployment, argon2id-hashed, and the plaintext
is printed to the terminal once when first generated — record it then (see
Authentication).
Never commit real secrets
Keep .env out of version control. It is already covered by .gitignore.
Use distinct credentials per deployment — each machine has its own root
.env.
Generated secrets¶
setup.sh generates and manages every secret below. "Length" is the length of
the generated value: lowercase hex, two characters per random byte.
| Secret | Length | Used for |
|---|---|---|
POSTGRES_PASSWORD |
64 hex | PostgreSQL owner role minder (migrations, schema) |
DB_APP_PASSWORD |
64 hex | Non-superuser runtime role minder_app. Each service's query pool connects as it, which is what makes row-level security apply |
DB_PLUGIN_PASSWORD |
64 hex | Least-privilege role minder_plugins that in-process plugins connect as. It is confined to the plugin_data schema and has no access to platform tables |
REDIS_PASSWORD |
64 hex | Redis |
RABBITMQ_PASSWORD |
64 hex | RabbitMQ |
MINIO_ROOT_PASSWORD |
64 hex | MinIO root user |
JWT_SECRET |
128 hex | Signs Minder JWTs |
MODEL_PROVIDER_VAULT_SECRET |
128 hex | Encrypts stored cloud-provider API keys. Never clear it, see below |
PLUGIN_SECRETS_VAULT_SECRET |
128 hex | Encrypts per-plugin connector secrets. Never clear it |
LICENSE_KEY_VAULT_SECRET |
128 hex | Encrypts license keys at rest. Never clear it |
LICENSE_KEY_HASH_SECRET |
128 hex | Keys the license-key lookup hash. Must never change |
NEO4J_AUTH |
neo4j/ + 32 hex |
Neo4j |
INFLUXDB_TOKEN |
80 hex | InfluxDB |
AUTHELIA_STORAGE_ENCRYPTION_KEY |
64 hex | Encrypts Authelia's own storage. Never clear it |
AUTHELIA_SESSION_SECRET, AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET, AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET |
64 hex | Authelia sessions, password-reset links, OIDC tokens |
MINDER_OIDC_CLIENT_SECRET |
64 hex | API Gateway's OIDC client secret with Authelia |
MINDER_AUTHELIA_ADMIN_PASSWORD |
64 hex (or your own) | Authelia admin login, printed once when generated |
GRAFANA_PASSWORD |
64 hex | Grafana admin |
WEBUI_SECRET_KEY |
64 hex | Open WebUI session signing |
SERVICE_SYNC_TOKEN |
64 hex | Internal service-to-service token (plugin AI-tool catalog sync) |
Without DB_PLUGIN_PASSWORD, plugins get no database handle at all (fail
closed). They never fall back to the owner credentials, and the plugin registry
logs an error at startup.
A new secret appeared after an upgrade¶
Releases sometimes add a secret to the list above (for example,
DB_PLUGIN_PASSWORD). setup.sh fills missing secrets on install/start.
To avoid desyncing live services, it refuses to generate secrets while the
stack is running, with one exception: a brand-new secret that its service
re-applies from .env on every boot. DB_PLUGIN_PASSWORD is one of these, and
setup adds it automatically even on a running stack, as long as the key is
absent from .env.
For any other new secret, or if the key is present but empty or a placeholder,
setup stops with Refusing to regenerate .env secrets. Either:
- stop the stack, then start it again (
bash setup.sh stop, thenbash setup.sh start), so setup fills the key while nothing is running; or - run setup with
MINDER_ALLOW_SECRET_REGEN=1set. This is an explicit opt-in that lets setup generate secrets on a live stack. Like a normal run, it only fills keys that are missing, empty, placeholders, or malformed. It never touches a well-formed value you already have.
File permissions¶
setup.sh sets 600 on .env automatically. To verify:
Credential rotation¶
Rotate on a regular cadence (quarterly is a reasonable default), and immediately
on suspected compromise. Before you start, take a backup (bash setup.sh backup).
Before setup regenerates any secret, it saves the current file as
.env.backup-<timestamp> next to .env.
Not every secret can be regenerated
Clearing a secret makes setup.sh replace it with a new random value.
That is fine for a password that a service re-reads on start. For a secret
that encrypts or hashes stored data, it orphans that data permanently:
| Secret | What breaks if it's regenerated |
|---|---|
MODEL_PROVIDER_VAULT_SECRET |
Stored cloud-provider API keys can no longer be decrypted |
PLUGIN_SECRETS_VAULT_SECRET |
Stored per-plugin connector secrets can no longer be decrypted |
LICENSE_KEY_VAULT_SECRET |
Encrypted license keys can no longer be revealed or rotated |
LICENSE_KEY_HASH_SECRET |
Existing license keys stop validating |
AUTHELIA_STORAGE_ENCRYPTION_KEY |
Authelia can no longer read its own encrypted storage |
Never clear these. If one was regenerated by mistake, restore the old
value from the newest .env.backup-* file before any further restart.
Regenerable secrets¶
These can be rotated by clearing them and letting setup generate new values:
- Clear the keys to rotate in the root
.env, for exampleJWT_SECRET=. - Apply with the stack stopped. Setup refuses to regenerate secrets while the stack is running.
-
Sync the ones a data store keeps. Editing
.envalone does not change a password that a data store saved at first initialization:Secret How the new value takes effect JWT_SECRETPicked up on restart. All existing tokens become invalid, so users sign in again REDIS_PASSWORD,MINIO_ROOT_PASSWORDPicked up when the container is recreated on start DB_APP_PASSWORD,DB_PLUGIN_PASSWORDPicked up on start: the services re-apply the minder_appandminder_pluginsrole passwords from.envon every boot. No manual step neededPOSTGRES_PASSWORDRun bash setup.sh sync-postgres-passwordonce the stack is up. It runsALTER USER minder, so the live database matches.env. It only updates theminderowner role; the two roles above sync themselvesRABBITMQ_PASSWORD,NEO4J_AUTH,INFLUXDB_TOKEN,GRAFANA_PASSWORDSaved in the service's data volume at first initialization. Setup has no sync command for these, so change the password inside the service with its own tooling, then put the same value in .env. Don't regenerate them blindly -
Verify:
Vault and license secrets (never clear)¶
The services accept MODEL_PROVIDER_VAULT_SECRET, PLUGIN_SECRETS_VAULT_SECRET
and LICENSE_KEY_VAULT_SECRET as a comma-separated list:
- the first entry is the primary key, used for all new encryption;
- every entry is tried when decrypting, so data encrypted under an older entry stays readable as long as that entry is still in the list.
Each entry has the same shape as a generated value: 128 lowercase hex
characters (openssl rand -hex 64). Setup accepts a valid list as it is and
never regenerates a vault secret that already has a value, whether the stack is
running or stopped, and even with MINDER_ALLOW_SECRET_REGEN=1. It only fills
one that is missing, empty or still a placeholder.
To rotate one of these secrets:
- Prepend the new key. Generate a new value and put it in front of the
current one, separated by a comma, in the root
.env: Never replace the old value or clear the list. - Restart so the services load the new list: New data is now encrypted with the new key, and existing data stays readable through the old one.
- Re-encrypt, where an endpoint exists. Model providers have one:
POST /v1/model-providers/rotate(admin or org admin). Call it once per organization to move that organization's stored keys onto the new primary.PLUGIN_SECRETS_VAULT_SECRETandLICENSE_KEY_VAULT_SECREThave no re-encryption endpoint, so keep their old entry in the list. - Drop the old key only after re-encryption. Once everything encrypted with
the old entry has been re-encrypted, remove it (
<NEW>alone) and restart again. If you're not sure, leave it in: an extra entry costs nothing, but a missing one makes the data it encrypted unreadable.
Setup stops on a malformed vault value
If a vault secret has a value that isn't a valid list (for example a
truncated entry, an uppercase or non-hex character, or a wrong length),
setup refuses to start. It logs which key is malformed and what it
expects, and writes nothing to .env. It doesn't replace the value,
because that would orphan the encrypted data. Fix the value by hand, or
restore it from the newest .env.backup-* file, then run setup again.
LICENSE_KEY_HASH_SECRET is a single value with no rotation path. Every
stored license-key lookup hash is keyed with it, so it must stay the same for
the life of the installation. It is kept separate from LICENSE_KEY_VAULT_SECRET
so that the vault key can rotate without touching the hashes.
On suspected compromise¶
Stop the stack first (bash setup.sh stop), rotate as above, restart, and
review the gateway access logs (docker logs minder-api-gateway --tail 1000).
Secrets management in production (optional)¶
Minder's built-in mechanism is the single root .env (one per machine — there is
no .env.development/.staging/.production layering). For a hardened
deployment you may optionally layer an external secrets system on top; these are
forward-looking, not built in:
Production hardening checklist¶
- Configure real DNS for the public hostnames.
- Replace the self-signed
.localcertificates with real TLS (ACME / Let's Encrypt via Traefik, or your own certs) so Authelia SSO/2FA works as full browser SSO. - Confirm no
CHANGEME_placeholders remain:grep CHANGEME_ .envshould print nothing. - Verify
.envpermissions are600and it is not committed to git. - Add firewall rules; remember host ports are loopback-bound, so protect access to the box itself.
- Establish a credential-rotation cadence.
- If you enable transactional email, keep
SMTP_PASSWORDin.envonly, usestarttlsortls(notnone), and publish SPF/DKIM/DMARC for the sender domain. - Add alerting on failed authentication attempts and anomalous access (see Monitoring).
- Keep Traefik and the service images updated (see Upgrading).
Verifying the basics¶
# No default credentials remain
grep CHANGEME_ .env # should print nothing
# File permissions
ls -la .env # -rw------- (600)
# Services healthy
bash setup.sh status
Troubleshooting¶
- A service exits immediately or is unhealthy — check its logs
(
docker logs minder-<service>); confirm.envexists and is populated. - "password authentication failed" (PostgreSQL) — the live DB password and
.envhave diverged; runbash setup.sh sync-postgres-password. - "WRONGPASS" (Redis) —
REDIS_PASSWORDin.envdoesn't match what Redis is running with; restart Redis after fixing it. - 401 on API requests — confirm
JWT_SECRETis consistent across services and the token hasn't expired. See Authentication.
More diagnostics are in Troubleshooting.