Proxmox VEXV · Security & HardeningAccess control
API tokens, privilege separation and secret rotation
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
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
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'# 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.
pveum acl modify /pool/production --tokens 'svc-backup@pve!nightly' \
--roles BackupRunner
pveum acl modify /storage/pbs-main --tokens 'svc-backup@pve!nightly' \
--roles BackupTargetAuditing what a token can do
The ACL list cannot answer this, because the intersection is not written anywhere. Ask:
pveum user token list svc-backup@pve
pveum user token permissions svc-backup@pve nightlyRun 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:
| Where | Exposure | What to do |
|---|---|---|
Terraform provider block | Committed to git, forever, in history | Environment variable or a secret backend; never a .tf file |
| Terraform state file | Plaintext in state, including remote state | Encrypted remote state with restricted access |
| Ansible inventory or vars | Committed unless vaulted | ansible-vault, or lookup from the secret store |
| CI/CD variable | Visible to anyone who can edit the pipeline | Masked, protected, scoped to one project |
| Monitoring agent config | World-readable config files are common | chmod 600, dedicated user, verify after every deploy |
| Shell history | ~/.bash_history on a shared jump host | Never paste a secret onto a command line |
| Backup of any of the above | Multiplies every row above | Note it in the rotation runbook |
| A screenshot in a ticket | Permanent, searchable, unrevoked | The reason the value goes to the secret store, not the terminal |
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 planThe 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.
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'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:
pvesh get /cluster/tasks --output-format json \
| grep -c 'nightly-2026q3' || echo 'no recent tasks under the old token'pveum user token remove svc-backup@pve nightly-2026q3The 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.
pveum user token remove svc-backup@pve nightlyThen, 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 0because 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 1means 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 permissionsis 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
Q1. A newly created token with --privsep 1 authenticates successfully but every API call returns a permission error. What is happening?
Q2. Which statements about privilege-separated tokens are correct? Select all that apply.
Q3. Disabling a user in Active Directory, or letting the realm sync remove them, does not stop that user API tokens from working.
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.