← Back to dashboard

Secrets Management

This document explains how the dashboard stores and protects credentials, the security philosophy behind the design, and how to migrate secrets to an external vault once you are ready.


Philosophy

The dashboard operates on a tiered secrets model. Every tier is more secure than the one before it. You start at Tier 1 on first run and can advance at your own pace.

Tier Where secrets live Who can read them Best for
1 — Encrypted database Application DB (AES-256) Anyone with DB access + JWT key Getting started; local dev
2 — External vault (migrated) AWS SM / Azure KV / GCP SM / BT Secrets Safe Vault IAM policy Shared or long-lived deployments
3 — Vault-backed cloud credentials External vault; fetched at runtime by BeyondTrust Password Safe audit log Regulated environments; zero-standing-access
4 — Dynamic cloud credentials Nowhere — minted per lease by BeyondTrust Workload Credentials Workload Credentials audit log Removing the standing cloud credential entirely

You do not have to climb this ladder. Tier 1 is secure enough for a single-user local deployment. Tier 2 is the right target for any shared or persistent environment. Tier 3 adds checkout audit trails and is worth the added complexity when compliance or separation-of-duties is a requirement. Tier 4 goes further and removes the standing cloud credential altogether.

Each tier is selected per cloud, so a mixed deployment is normal rather than a half-finished migration — AWS on Tier 4 alongside GCP on Tier 1 is a supported configuration, not a gap. Nothing here changes on upgrade: a deployment stays on whichever tier it is on until an operator moves it.


What counts as a secret

Category Examples Where stored
Cloud provider credentials AWS access key + secret, Azure SP client secret, GCP service account JSON Encrypted DB (Tier 1) or external vault (Tier 2+)
Integration API tokens Portainer PAT, Entitle API token, BeyondTrust client secret Encrypted DB (Tier 1) or external vault (Tier 2+)
SSH private keys EC2 keypair, GCP SSH key External vault only (AWS SM / GCP SM); never in the DB
JWT root key .jwt_secret_key Host filesystem (owner-read-only) or Docker secret — see why this can't be migrated
Database password POSTGRES_PASSWORD .env (non-secret bootstrap; not an application credential)

The database password in .env is intentionally left there — it is only reachable from within the Docker Compose network and protects the DB service process, not application data. It is not a credential the application uses to authenticate to external services.


Tier 1 — Encrypted database (default)

All credentials entered through the setup wizard or Settings → Integrations are encrypted with AES-256 before being written to PostgreSQL. The encryption key is derived from the JWT root key (.jwt_secret_key).

What this means in practice:

Limitations of Tier 1:


Tier 2 — External vault (migration)

The Settings → Secrets Backend page (/secrets) lets you migrate any stored secret from the encrypted database to an external vault. After migration, the database stores only a reference string (e.g. aws_sm://dashboard/my-key). When the application needs the value, config_service resolves the reference by calling the vault API, caches the result for 5 minutes, and discards it.

Supported backends

Backend Reference prefix Auth method
AWS Secrets Manager aws_sm:// IAM credentials already configured in the dashboard
Azure Key Vault azure_kv:// Service principal already configured in the dashboard
GCP Secret Manager gcp_sm:// Service account already configured in the dashboard
BeyondTrust Secrets Safe bt_safe:// ps-cli credentials already configured in the dashboard
BeyondTrust Workload Credentials wlc:// Site ID + personal access token (preview — see below)

Each backend reuses the cloud provider credentials you have already entered — no additional IAM setup is needed beyond granting the existing SP / SA / IAM user access to the vault.

How to migrate a secret

  1. Log in as admin and navigate to Settings → Secrets Backend (/secrets).
  2. Select the backend you want to migrate to.
  3. Choose the secret to migrate and enter the target secret name in the vault.
  4. Click Migrate. The dashboard: - Reads the current plaintext value from the encrypted DB. - Writes it to the vault under the name you specified. - Replaces the DB value with the reference string. - Verifies the reference resolves correctly before committing.
  5. The plaintext value is no longer stored in the database. Future reads go to the vault.

Why the JWT root key cannot be migrated

The JWT root key derives the Fernet key that encrypts every value in the application database — including the cloud credentials the dashboard would need to call any external vault. There is no startup ordering in the community edition that resolves this:

read JWT root key
  └─ derive Fernet DEK
       └─ decrypt DB row
            └─ get vault credentials
                 └─ call vault to fetch JWT root key  ← the loop

The community edition therefore reads the JWT root key from one of these sources only, in order:

  1. The path in JWT_SECRET_KEY_FILE (set by docker-compose.yml to /run/secrets/jwt_key when the host file exists).
  2. /run/secrets/jwt_key directly (Docker / Compose secret mount).
  3. The JWT_SECRET_KEY environment variable.
  4. A freshly generated random key — only suitable for first-run/dev; it does not survive a restart and invalidates all existing sessions and DB-encrypted values.

The hosted SaaS edition removes this limitation by fetching the root key from Azure Key Vault using a workload-managed identity and OIDC federation — no static credential is required to bootstrap. See SaaS comparison for details.

To rotate the JWT root key in the community edition: stop the application, write a new key to the file/secret, restart. All existing sessions are invalidated and every DB-encrypted value must be re-entered through the setup wizard / Settings panels. Plan rotation accordingly.

IAM permissions required per backend

AWS Secrets Manager:

secretsmanager:GetSecretValue
secretsmanager:CreateSecret
secretsmanager:PutSecretValue
secretsmanager:DescribeSecret

Scope to arn:aws:secretsmanager:<region>:<account>:secret:dashboard/* (or your configured prefix).

Azure Key Vault: The service principal needs the Key Vault Secrets Officer role on the vault, or a custom role with Microsoft.KeyVault/vaults/secrets/read and Microsoft.KeyVault/vaults/secrets/write.

GCP Secret Manager:

roles/secretmanager.secretAccessor
roles/secretmanager.secretVersionAdder

Scoped to the specific secrets, or roles/secretmanager.admin on the project.

BeyondTrust Secrets Safe: The API Registration needs Secrets → Read and Secrets → Write permissions on the folder where dashboard secrets will be stored.


Browse & Edit — full CRUD on individual secrets

Migration covers the "move everything across" path. For day-to-day work the /secrets page also provides full CRUD on individual secrets in any configured backend — no need to drop into the AWS console, Azure Portal, GCP console, or ps-cli for one-off edits.

The Browse & Edit section lets you:

JSON-only values

All secret values are constrained to valid JSON — same format across every backend. The editor enforces this client-side (live parse) and server-side (validate_json_value raises before the backend write). The default scaffold for new secrets is:

{
  "username": "",
  "password": ""
}

Add whatever fields your consumers expect — multi-line bodies (private keys, certificates) work fine inside a JSON string. The Format JSON button in the editor pretty-prints the current value to canonical multi-line layout. This uniformity means downstream code can json.loads the value from any backend without backend-specific parsing.

What the dashboard can and cannot do per backend

Operation DB AWS SM Azure KV GCP SM BT Secrets Safe
List secrets ✅ (per folder)
Read secret value
Create / update secret
Delete secret
List Safes
Create / rename / delete Safe
List Folders
Create / delete Folder

BeyondTrust hierarchy management is driven through the ps-cli subcommands: list-safes / create-safe / update-safe / delete-safe for Safes, and list / create / delete (parent identified via -pid) for Folders. Deletes require the container to be empty — ps-cli refuses to remove a Safe that still has Folders or a Folder that still has Folders or Secrets, and the dashboard surfaces that error verbatim.


Non-goals — what Browse & Edit deliberately does not include

The Browse & Edit feature is intentionally lightweight day-to-day CRUD. It is not a replacement for the audit-and-compliance capabilities of an enterprise vault (BeyondTrust Password Safe, CyberArk PAM, HashiCorp Vault Enterprise, AWS Audit Manager + CloudTrail, etc.):

If you spot a per-secret operation in this page that should land in your vault's audit log but doesn't, treat that as a sign you should do that operation in the vault's native UI instead — not as a gap to file against the dashboard.


Tier 3 — Vault-backed cloud credentials (BeyondTrust)

With the BeyondTrust integration enabled, the dashboard can retrieve AWS, Azure, and SSH credentials directly from Password Safe at runtime rather than from the application database. See docs/integrations/password-safe.md for setup instructions.

How it differs from Tier 2:

Tier 2 Tier 3 (BeyondTrust)
Where credentials live External vault BeyondTrust Password Safe
Checkout record No Yes — every retrieval creates a Password Safe audit entry
Rotation Manual vault update Rotate in Password Safe; dashboard gets new value immediately
SSH key Stored as vault secret Managed Account checkout (key never written to disk)
Requires BeyondTrust licence No Yes (Secrets Safe)

Tier 4 — dynamic cloud credentials (Workload Credentials)

Tier 3 still keeps a credential somewhere and fetches it when needed. Tier 4 has nothing to fetch: BeyondTrust Workload Credentials mints a short-lived AWS or Azure credential on demand, and it expires on its own.

This is a preview feature and is off by default — the product is not yet generally available. It is enabled under Settings → Preview features, per cloud, and turning it on is also what unlocks retiring that deployment's own static keys. Workload Credentials additionally works as a Tier 2 backend for ordinary static secrets (wlc://), which is the cheapest way to verify the connection before anything dynamic is switched on.

One caveat stated plainly: the dashboard still holds a Workload Credentials personal access token, so this collapses three standing cloud credentials into one platform token rather than eliminating secrets outright.

See integrations/workload-credentials.md.

Hypervisor credentials for a remote agent

An agent-bound hypervisor connection can take its credential from any of the tiers above, which is what lets an on-prem agent host store no hypervisor password at all. The connection's secret_ref accepts the usual backend prefixes, plus one that is specific to this path:

ps_account://<managed-account-id> — the dashboard checks a credential out of Password Safe for the duration of one agent job, seals it to that agent, checks it back in when the job ends, and rotates the password on release so the value the agent held is dead immediately. Combined with dashboard_secret: true in the agent's own connections.yaml, neither the agent host nor the application database holds a standing hypervisor credential.

Unlike the four backend prefixes, ps_account:// is deliberately not resolvable through config_service.get(). The others are stateless reads; this one opens a request that something has to close, so it is handled only on the agent-credential path where the release lifecycle exists. See docs/remote-agents.md.


Using a secret in an Ansible run (without seeing it)

Stored secrets aren't only consumed by cloud-credential lookups — an operator can inject one directly into a Config Management run without ever seeing the value. On /config-mgmt the Use a secret panel offers three bindings, resolved just-in-time, scrubbed from job output, and audited (kinds + var names only, never the value):

Binding Becomes Runners
Named variable an extra var (-e) local + cloud
Become / sudo password ansible_become_password (no_log) local + cloud
SSH private key the connection key local + cloud

Managed-account checkout (Tier 3): with BeyondTrust enabled, the same panel also lets the operator pick a Password Safe managed account from a live list and use it as the login identity — the credential is checked out just-in-time (an SSH-key account → the connection key; a password account → ansible_ssh_pass). Managed-account checkout works on the local and Azure (ACI) runners (both inject inline); on ECS / Cloud Run it requires the opt-in below.

Full operator detail lives in docs/integrations/ansible.md → Using a Secrets-Management secret in a run.

Ephemeral cloud secrets

The ECS and Cloud Run runners inject a secret by referencing one in that cloud's store (the task identity fetches it at launch); a just-in-time managed-account credential has no such store entry. Enabling Ephemeral cloud secrets (Settings → Ansible, off by default) bridges that: for the run, the dashboard

  1. checks the credential out of Password Safe,
  2. writes it to the cloud store as a short-lived, RBAC-locked secret,
  3. injects it via the provider's secret channel (ECS valueFrom / Cloud Run secret-env), and
  4. force-deletes it in a finally when the run ends.

RBAC lock-down — only the runner can read it:

Cloud How access is restricted
GCP The Cloud Run job runs as a configured runner service account, and roles/secretmanager.secretAccessor is granted on that one secret to only that SA (no project-level accessor). Requires gcp_ansible_runner_service_account.
AWS A resource policy scopes GetSecretValue to the ECS execution role. For a cryptographic lock, set ansible_ephemeral_kms_key_id to a CMK whose key policy grants kms:Decrypt to only that role — then reading requires decrypt permission even for account IAM admins.

Rotate-on-release (best-effort). The dashboard uses a long-enough Password Safe request (ansible_managed_request_duration_min, default 60 min — must outlast the run) so the request is still open to flag rotate-on-check-in and then check it in after the run. Rotation isn't enforceable (it depends on the account being auto-managed), so the reliable backstop is enabling Change Password After Release on the managed account — either way a missed cleanup then leaves a rotated, dead credential.

Garbage collection. Each run reaps its own ephemerals; a sweeper (on startup and before each run) force-deletes any tagged ephemeral older than the TTL, covering a crash between create and cleanup.

The trade-off. This briefly writes a PAM-vaulted credential into the cloud store (a second trust domain) for the task's lifetime. Mitigations shrink but don't erase that: the per-secret RBAC lock, optional CMK, short request duration, rotate-on- check-in / Change-After-Release, force-delete + GC, and dashboard-side audit of the use. If that trade-off isn't acceptable, use the local or ACI runner (inline, no store copy) — the default.

Full operator detail: docs/integrations/ansible.md → Managed-account checkout.


Secret staleness — age alerting

The dashboard can flag stored secrets that haven't changed in a while, so a long-forgotten credential doesn't sit un-rotated indefinitely. It's read-only — it never rotates or touches the secret; it only surfaces an age.

This is deliberately the safe half of secret lifecycle — staleness signalling only. Automated rotation is a hosted-edition concern (see the SaaS roadmap).


Security best practices

Do immediately after first run: - [ ] Verify the JWT root key file (.jwt_secret_key or /run/secrets/jwt_key) has owner-only read permissions. - [ ] Back up the JWT root key to a secure offline location — losing it renders the entire encrypted database unrecoverable. - [ ] Change the auto-generated admin password (Settings → Security → Change Password).

For any shared or long-lived deployment: - [ ] Migrate all cloud provider credentials (AWS, Azure, GCP) to your vault. - [ ] Restrict vault IAM policies to the minimum permissions listed above. - [ ] Enable MFA for the dashboard admin account (Settings → Security → Security Keys). - [ ] Set POSTGRES_PASSWORD in .env to a strong unique value (the onboard script auto-generates one — do not replace it with something weaker). - [ ] Do not expose the PostgreSQL port (5432) outside the Docker Compose network. The docker-compose.yml publishes it for convenience; remove the ports: block under db: for any internet-facing deployment.

If BeyondTrust is available: - [ ] Configure Password Safe as the credential source for AWS and Azure. - [ ] Store SSH private keys as Managed Accounts rather than Secrets, so checkout requires an active session and creates a full audit record.


What is never stored as a secret