Skip to main content
RunBook Academy

Proxmox VEXV · Security & HardeningAccess control

API tokens, privilege separation and secret rotation

Advanced⏱ ~28 minpveum

What you'll learn

  • Create an API token with privilege separation and an expiry, scoped to one job
  • Explain how token permissions are derived and why the ACL list alone cannot tell you
  • Identify where a token secret ends up outside Proxmox and reduce that footprint
  • Rotate a token in production without an outage, and revoke one under compromise

Prerequisites

Verified against Proxmox VE 9.2.4 · Proxmox Backup Server 4.2.5 · Ceph Squid / Tentacle · Debian 13 (Trixie) · Linux kernel 7.0 (PVE 9.2 default) · 2026-08-12

Not yet marked complete on this device.

An API token is a credential that authenticates as a user without that user’s password, without a login, and without any possibility of a second factor. That last point is the one that determines how tokens should be treated: a token is the one credential in Proxmox that TFA cannot protect, because there is no interactive session in which to challenge anybody.

Which makes scope and lifetime the only controls available. Both are optional, both default to the permissive answer, and neither leaves a trace when it is skipped.

Anatomy

A token belongs to a user. Its full identifier is three parts:

alice@corp!terraform

user, !, token ID. The secret is a UUID, shown once, and the credential sent on the wire is the two joined:

Authorization: PVEAPIToken=alice@corp!terraform=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

Creating one

Configuration changea scoped, separated, expiring token
EXPIRY=$(date -d '+90 days' +%s)

pveum user token add svc-backup@pve nightly \
--privsep 1 \
--expire "$EXPIRY" \
--comment 'PBS nightly job, owner platform-team, rotate quarterly'
Configuration changethe one time the secret appears
# pveum user token add svc-backup@pve nightly --privsep 1 --expire 1786500000
┌──────────────┬──────────────────────────────────────┐
│ key          │ value                                │
╞══════════════╪══════════════════════════════════════╡
│ full-tokenid │ svc-backup@pve!nightly               │
├──────────────┼──────────────────────────────────────┤
│ info         │ {"privsep":"1","expire":"1786500000"}│
├──────────────┼──────────────────────────────────────┤
│ value        │ REPLACE_ME-token-secret-uuid         │
└──────────────┴──────────────────────────────────────┘

Illustrative output

The --comment is not decoration. A token with no comment is a credential with no owner, and the first question in every access review is who uses this and what breaks if it goes away. Put the owning team and the rotation cadence in it.

Privilege separation

--privsep 1 is the default, and it is the difference between a token that does one job and a token that is a copy of a person.

From the documentation, the two modes:

Separated privileges (default): the token needs to be given explicit access with ACLs. Its effective permissions are calculated by intersecting user and token permissions.

Full privileges: the token’s permissions are identical to that of the associated user.

The intersection is the important word. A separated token’s effective permission on a path is the smaller of what the user has there and what the token has there. Two consequences, and they pull in opposite directions:

A separated token can never exceed its user. “Privilege separated tokens can never have permissions on any given path that their associated user does not have.” Granting the token a role on a path the user cannot reach produces an ACL entry that resolves to nothing at all.

A separated token with no ACL entries can do nothing. It authenticates successfully and every subsequent call returns a permission error. This is the single most common “my token does not work” report, and it is the system behaving correctly: creating the token and granting the token are two operations.

Configuration changecreating a token is not granting it
pveum acl modify /pool/production --tokens 'svc-backup@pve!nightly' \
--roles BackupRunner
pveum acl modify /storage/pbs-main --tokens 'svc-backup@pve!nightly' \
--roles BackupTarget

Auditing what a token can do

The ACL list cannot answer this, because the intersection is not written anywhere. Ask:

Read-only / Safethe only honest answer
pveum user token list svc-backup@pve
pveum user token permissions svc-backup@pve nightly

Run this on every token during an access review and compare the result against the comment describing what the token is for. The recurring finding in production estates is a token holding more privilege than its purpose, and this pair of commands is what surfaces it in seconds.

Expiry

--expire takes a Unix timestamp. After it passes, the token stops authenticating.

That is a blunt instrument and it is still worth using, because the alternative is a credential with no end. The failure mode is honest: the job breaks, loudly, on a date you chose, rather than the credential outliving the person who created it and the system it was for.

Two habits make it survivable:

Alert before the date, not on it. Nothing in Proxmox warns you that a token is about to expire. Export the expiry into whatever tracks certificate renewals — it is the same class of problem and the same consequence.

Give the expiry a value that matches the job. A token for a two-week migration gets a two-week expiry. A permanent backup job gets a quarterly one aligned with the rotation you were going to do anyway.

Where the secret ends up

The token is created once and then copied, and every copy is a place it can leak. The realistic inventory for a Proxmox estate:

WhereExposureWhat to do
Terraform provider blockCommitted to git, forever, in historyEnvironment variable or a secret backend; never a .tf file
Terraform state filePlaintext in state, including remote stateEncrypted remote state with restricted access
Ansible inventory or varsCommitted unless vaultedansible-vault, or lookup from the secret store
CI/CD variableVisible to anyone who can edit the pipelineMasked, protected, scoped to one project
Monitoring agent configWorld-readable config files are commonchmod 600, dedicated user, verify after every deploy
Shell history~/.bash_history on a shared jump hostNever paste a secret onto a command line
Backup of any of the aboveMultiplies every row aboveNote it in the rotation runbook
A screenshot in a ticketPermanent, searchable, unrevokedThe reason the value goes to the secret store, not the terminal
Configuration changepass a token to a tool without putting it on the command line
install -m 600 /dev/null /etc/pve-automation/token.env
# write the secret into that file with an editor, not with echo

set -a
. /etc/pve-automation/token.env
set +a

terraform plan

The process-list detail is easy to miss and worth stating: an argument passed on a command line is visible in ps to every user on the host for as long as the process runs. An environment variable is not, and a file read at startup is not.

Rotating without an outage

The naive rotation — delete the token, create a new one, update the consumer — has an outage in the middle of it and no way back if the new credential is wrong. The overlap procedure has neither.

A token ID is part of the identifier, so a user can hold two tokens at once with different IDs and different secrets. That is the whole trick.

Configuration changestep 1 — create the replacement alongside the current one
EXPIRY=$(date -d '+90 days' +%s)
pveum user token add svc-backup@pve nightly-2026q4 \
--privsep 1 --expire "$EXPIRY" \
--comment 'PBS nightly, rotation of nightly-2026q3, owner platform-team'
Configuration changestep 2 — grant it the same ACLs, then prove they match
pveum acl modify /pool/production --tokens 'svc-backup@pve!nightly-2026q4' \
--roles BackupRunner
pveum acl modify /storage/pbs-main --tokens 'svc-backup@pve!nightly-2026q4' \
--roles BackupTarget

pveum user token permissions svc-backup@pve nightly-2026q3 > /tmp/tok-old.txt
pveum user token permissions svc-backup@pve nightly-2026q4 > /tmp/tok-new.txt
diff /tmp/tok-old.txt /tmp/tok-new.txt && echo 'permissions match'

Step 3 — update the consumer to the new token and let it run one full cycle. For a nightly backup that means one night. Do not skip the cycle: a permission that is only exercised during the job, such as writing to a second datastore on the monthly run, will not fail until the monthly run.

Step 4 — confirm the old token is idle before removing it. Task history is the evidence, and it is worth checking rather than assuming you found every consumer:

Read-only / Safestep 4 — is anything still using the old token?
pvesh get /cluster/tasks --output-format json \
| grep -c 'nightly-2026q3' || echo 'no recent tasks under the old token'
Service impact possiblestep 5 — remove the old token
pveum user token remove svc-backup@pve nightly-2026q3

The rollback at every step before 5 is to point the consumer back at the old token, which still exists and still works.

Revocation under compromise

Rotation is planned and reversible. Revocation is neither, and the order of operations is different: remove the access first, investigate second.

Service impact possiblerevoke a compromised token
pveum user token remove svc-backup@pve nightly

Then, in order: list what the token could reach, so the investigation has a scope; read the task log for anything it did; and check whether the same secret was reused anywhere else, because a secret in a git history is usually in more than one repository.

If the token was --privsep 0 on a privileged user, the blast radius is the user’s entire permission set and the incident is larger than the token.

Common mistakes

  • --privsep 0 because the separated token returned a permission error. The error meant the token had no ACL. Grant it one.
  • Tokens owned by root@pam. Create a purpose-built user; a token cannot be narrower than its owner.
  • No --expire. A credential with no end date outlives its purpose, its owner and its documentation.
  • No --comment. An unattributed credential cannot be reviewed, because nobody will delete something they cannot prove is unused.
  • Auditing the ACL list instead of pveum user token permissions. The intersection is not written down anywhere else.
  • Rotating by deleting first. Create, grant, cut over, verify, then delete.
  • Assuming offboarding removes tokens. It does not. The realm sync does not touch them and disabling the directory account does not either.

Key takeaways

  • A token is the only Proxmox credential that cannot carry a second factor. Scope and expiry are the controls that remain.
  • The secret is shown once and is stored hashed. There is no recovery.
  • --privsep 1 means effective permissions are the intersection of user and token. A separated token with no ACL can do nothing, and that is correct behaviour.
  • pveum user token permissions is the only place the intersection is visible.
  • Rotate with overlap: create, grant, verify the permissions match, cut over, run a full cycle, confirm idle, delete.
  • Revoke first and investigate second. Removal is cluster-wide on the next request.
  • Tokens survive the offboarding of their owner unless someone removes them explicitly.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A newly created token with --privsep 1 authenticates successfully but every API call returns a permission error. What is happening?

  2. Q2. Which statements about privilege-separated tokens are correct? Select all that apply.

  3. Q3. Disabling a user in Active Directory, or letting the realm sync remove them, does not stop that user API tokens from working.

  4. Q4. You are rotating the token used by a nightly backup job. Which sequence avoids both an outage and an unverified cutover?

Passing score: 75%. Answers are checked in this browser.