BeyondTrust/POV

← Back to dashboard

Credentials an agent uses

Audience: operator · Profile: both · Read this when: you are deciding where a target's credential lives — the dashboard, or the host.

Part of Remote Agents. What the dashboard holds, what the host seals, and where a credential comes from at run time.

Keep each credential in the dashboard, attest the agent through SPIRE, and let the agent fetch the credential per job. In practice:

  1. Migrate the agent to SPIRE. Use Migrate to SPIRE on the Agents page and the docker-compose.spire.yml overlay; Attesting an agent through SPIRE walks through it. After that the agent's signing key lives only in memory, and every restart attests again.
  2. Set dashboard_secret: true on the connection in connections.yaml, and delete the local password, password_file or password_sealed.
  3. Set the credential on the Connections tab. A Password Safe managed account (ps_account://) is best, so that the dashboard keeps no standing password either. The tab's Credential column then reads Held by dashboard, names the source (Secrets Safe, a Password Safe account, a cloud vault, or the dashboard's own database), and shows when the agent last fetched it. Not fetched by the agent yet means the agent's entry does not say dashboard_secret: true yet, or no job has run since you changed it. Only fetches from the last 30 days count.
  4. Tick "Release dashboard-held credentials only to SPIRE-attested agents" under Settings → Remote agents (dashboard_secrets_require_spire). Once every agent that needs a credential has migrated, this stops an Ed25519 key from receiving credentials, including one copied off a host.

File shares work the same way: put dashboard_secret: true on the share's entry in shares.yaml and set the SMB password on /storage (Storage Management). Its releases are audited as agent.share_secret.

Earlier versions of these docs treated a host-side credential as the default and central storage as a trade. That advice was right for an agent whose identity is a file.

With an Ed25519 agent, identity.json sits unencrypted in the state volume, and that key is what asks for a dashboard_secret credential. Moving the credential to the dashboard therefore replaced a password in one file with the ability to request that password, stored in another file. That was a real improvement (narrow, audited, revocable), but anyone who could read the host's disk still left with something that worked.

SPIRE removes that file:

Two limits remain.

Root on a running agent host. Someone with root on the host while the agent runs can read its memory, including the in-memory key and any credential a running job holds. No storage choice prevents that. Central storage bounds it: the attacker gets the credentials of jobs that run while they are present, each one audited, and nothing durable once they are evicted and the agent restarts.

A compromised dashboard. It holds a credential that is stored on the Connections tab. It does not hold one that is a ps_account:// reference: the dashboard checks the account out per job, and the account's Password Safe policy, approval workflow and rotation still apply. This is why step 3 recommends a reference. Either way the dashboard still cannot aim a credential: host and username stay in your connections.yaml, gated by your policy.yaml. See Philosophy.

Host-side storage stays supported. password_sealed, password_file and ps_managed_account keep working, and an Ed25519 agent keeps receiving dashboard_secret credentials unless you tick the setting in step 4. Use host-side storage when a site will not let a credential leave it, or when an agent behind an inspecting proxy cannot reach a SPIRE server. In the second case dashboard_secret with an Ed25519 agent is still better than a password in a file.

With the setting ticked, an Ed25519 agent asking for a dashboard-held credential gets 403 … releases the credentials it holds only to agents attested through SPIRE. That covers hypervisor credentials, file-share passwords, Gateway deploy keys and Config-Management bundles alike. The check runs after job ownership, so an agent asking about a job that isn't its own still gets the same answer as for a job that doesn't exist.

The credential the dashboard holds

Set dashboard_secret: true on a connection in your connections.yaml and the agent stops reading a credential locally. Instead, for each job, it asks the dashboard.

Two things make that safe to do over the same channel the agent polls on:

The seal is bound to the agent, the job and the connection ref. That last one is the least obvious and the most important: without it a credential released for dc1-vcenter could be relabelled as the credential for a connection pointing somewhere else, which turns credential confusion into credential exfiltration.

The credential is held in memory for the job and scrubbed out of anything the agent sends back — Live Output and the job's error string both — because str(exc) from an arbitrary library can carry it, and that string is the only text a failed job renders. Nothing here claims to wipe it from memory: a Python string cannot be zeroed, and the bound is scope.

Requires an agent image of 2.1.0 or newer. The dashboard refuses to queue work for an older one rather than let it fail: a 2.0 agent does not know the key, so it would fall through to a password you had just deleted, send an empty one, and get back the hypervisor's own "wrong username or password" — the wrong diagnosis, and on the 30-minute sync schedule it retries until the service account locks out. Pull the image and restart; re-enrolment is not needed.

Sealing a credential this host keeps

Some sites will not move a credential to the dashboard — that being the point of an on-prem agent for them — but do not want it sitting in a YAML file as text. If yours can, prefer central storage with SPIRE: sealing is the better local option, not the better option. password_sealed: in connections.yaml, and client_secret_sealed: in passwordsafe.yaml, hold a value encrypted against a key in the agent's state volume.

Be precise about what this defends against, because The agent's private key argues the opposite for identity.json and both statements are true. Sealing is not protection from root on the agent host. The key is on the same machine, which is unavoidable for a process that has to restart unattended — a passphrase an unattended container must read at boot has to be stored next to the key it protects, and that has not changed.

What it protects is the file travelling. connections.yaml is a file you author, edit, version and copy: into a git repo, an Ansible role, a runbook, a support ticket, a screenshot, a config backup. identity.json does none of that — it is written by the agent into a 0700 volume and never leaves it. The key lives in that volume, so it goes into none of those copies, and a copy of the config is worthless anywhere else. That is the whole claim. If your threat model is a compromised agent host, use dashboard_secret or ps_managed_account instead; those are the rows in the table above that change what host compromise yields.

Seal a value with the agent image itself. Mount the same state volume the agent runs with:

docker run --rm -it -v dashboard_agent_state:/var/lib/dashboard-agent \
    chrweav/dashboard-agent:latest seal --host vcenter.lab.internal
docker run --rm -it -v dashboard_agent_state:/var/lib/dashboard-agent `
    chrweav/dashboard-agent:latest seal --api-url https://passwordsafe.corp.internal

It prompts for the value without echoing it, prints one line on stdout, and that line is what you paste in. Everything else it says goes to stderr, so seal … > value.txt gives you the token alone. A prompt rather than an argument is deliberate: on Windows it is the only way to keep the value out of the shell's on-disk history, the same reason the enrolment code has a file form.

Three things about it that are load-bearing rather than incidental:

Requires an agent image of 2.2.0 or newer, and the dashboard cannot warn you — unlike dashboard_secret it never sees this file, so it has nothing to gate on. You cannot reach that state by accident either, because seal ships in the same image that reads the key. The backstop is that the agent now refuses a connection declaring no credential at all rather than sending an empty password, which is the shape an older image on a sealed-only entry would otherwise produce.

Where the credential comes from

Five sources. A remote source beats a local one — ps_managed_account or dashboard_secret, then password_sealed, then password_file, then an inline password — because an operator who has moved a connection off local storage should not silently keep authenticating with a stale literal left in the file underneath it. A leftover is warned about on every job rather than ignored quietly, since it means plaintext is still sitting on a host you meant to clear.

password_sealed is not a fourth authority; it is the same local credential, not written down in the clear. That is why it is ordered against the two plaintext forms rather than being exclusive with them, and why a sealed value found in password: — or in the file password_file points at — is refused rather than sent. Sent as a literal it would come back as a wrong password, which reads as the wrong problem entirely.

A connection that declares none of the five is refused, naming all five keys. It used to fall through to an empty password, which the endpoint answers as a wrong one, so a connection that simply had no credential looked like a connection with a bad one — and on the inventory sync schedule it retried until the service account locked out.

The two remote sources are mutually exclusive rather than ordered. They are different authorities — the agent asking Password Safe, versus the dashboard asking on its behalf — there is no stale-leftover story that makes preferring one kind, and choosing quietly would leave nobody able to say which credential a job actually used. Declare both and the agent refuses the job and names the file.

password_file — and client_secret_file in passwordsafe.yaml — are read with the same encoding rules as the enrolment code file: a UTF-8 BOM is stripped, and a UTF-16 file is refused with a message naming the encoding rather than a decode traceback out of a job. Write them with Set-Content -Encoding ascii -NoNewline on Windows.

With ps_managed_account, the agent holds no hypervisor credential at all — only a Password Safe OAuth client whose single power is to ask for one. Each job checks a credential out and checks it back in, so every use lands in Password Safe's audit trail and is subject to its policy and approval workflow. That gets the agent close to the end state this design was heading for: no standing hypervisor credential on the host. Central storage with SPIRE is that end state.

It does not get all the way there, and the remaining gap is worth naming: the Password Safe OAuth client is itself a credential on this host, and its entitlements are usually broader than the single account it is being used for. dashboard_secret closes that gap by moving the asking to the dashboard, which already holds such a client for other features. Pick one — declaring both is a refusal, not a precedence.

Mount the client alongside the other two files — see passwordsafe.example.yaml. The dashboard never issues it and never sees it.

A check-in failure is swallowed deliberately: the credential has already been used and the request expires on its own duration, so failing a completed job over it would be the wrong trade. An account that requires human approval will not release, and the job fails rather than hanging — use auto-approve policies for accounts an unattended agent needs.