Password Safe: Kubernetes token rotation
Audience: operator · Profile:
both· Read this when: you want the ServiceAccount token a PRA Kubernetes tunnel injects to be a Password Safe managed account that rotates, with the PRA Vault copy kept current.
Part of Password Safe.
Kubernetes ServiceAccount token rotation
A cluster's PRA k8s tunnel can inject a ServiceAccount bearer token at session launch (Kubernetes → Access & identity). The dashboard used to mint that token once and never touch it again. This makes it a Password Safe managed account so it rotates on the tenant's schedule, and keeps the PRA Vault copy current.
This path needs both products:
password_safe_enabledfor the rotation itself andpra_enabledfor the tunnel identity the token belongs to. With PRA off there is no injected ServiceAccount to manage.
Two custom plugins, both imported by hand:
| Plugin | Platform (default name) | Role |
|---|---|---|
| Kubernetes Service Account Token | Kubernetes Service Account Token |
Rotates the token in the cluster |
| PRA Vault Token | PRA Vault Token |
Writes the rotated value into the PRA Vault opaque_token account |
Enable Kubernetes Token Rotation in Settings → Integrations → Password Safe (the Kubernetes tab), then use the
per-cluster Token rotation button on /k8s (or tick the box on the provision form).
Registration applies the rotator RBAC, creates the managed system + account, registers the
PRA Vault Token account, syncs it to the token account, rotates once to prove the path,
and finally deletes the dashboard-minted <sa>-token Secret.
The registration rotation is not optional here. A managed account cannot be created
holding a bearer token: the REST path that sets a password on create caps it at 128
characters and a ServiceAccount JWT is 800–1,200, so the account starts out holding a
throwaway placeholder. (The cap is specific to that path — a plugin's rotation write-back
carries multi-KB values, which is how the SSH-key plugins store 3.2 KB private keys.) The
first rotation is therefore what puts a real credential in the vault, and turning
k8s_ps_token_change_on_register off does not skip it — registration overrides the setting
and warns, because a vault serving a placeholder to the PRA tunnel looks exactly like a
working registration.
The sync is the step that matters, and it is a Password Safe feature rather than anything the dashboard runs: the PRA Vault account becomes a subscriber of the token account, and a managed account and its subscribers always share an identical credential. The link is created before the registration rotation on purpose — a failure there has changed nothing in the cluster, whereas rotating first and failing to link would leave PRA holding a value that was just revoked and that nothing would refresh.
Why that last step matters. The plugin's rotation sweep selects Secrets by its own labels, so the dashboard-minted Secret is never swept — leaving it in place would mean rotation revokes nothing and a permanent cluster-admin bearer token stays valid forever. The ordering is deliberate too: it is deleted only after PRA carries a Password-Safe-issued token, because until then it is the credential live sessions are using.
Managed system address
The address is the plugin's only per-cluster configuration surface (a .psplugin is
checksum-sealed, so its packaged settings cannot be edited), capped at 249 characters:
eks;<region>;<clusterName>[;option…]
aks;<subscriptionId>;<resourceGroup>;<clusterName>[;option…]
gke;<projectId>;<location>;<clusterName>[;option…]
k8s;<apiServerUrl>[;option…]
The dashboard builds it from the cluster row plus its deploy job's Terraform variables. For
a registered cluster those are unknown, so the modal accepts a cloud cluster name and
(for AKS) a resource group. OKE and on-prem clusters use the generic k8s; form — the
plugin has no OCI provider. For GKE the location must be the zone for a zonal cluster
and the region for a regional one; mixing them is the documented cause of a 404.
Options appended automatically: ;longlived or ;bound (+ ttl=), ;ns=<namespace>, and
anything in k8s_ps_token_address_options (e.g. dnsEndpoint=true, serverName=,
allowHostnameMismatch=true, ca= on generic addresses only).
Functional accounts
One per cloud, created by the operator — the dashboard references them by name and never holds a cloud secret. The managed system inherits the functional account's platform, so a functional account on the wrong platform is refused with the platform named.
| Cloud | Username | Password |
|---|---|---|
| GKE | service account email (or impersonate:<target>) |
base64 of the whole JSON key file |
| EKS | AWS access key id (or InstanceProfile / WebIdentity) |
<secret> or <secret>:<sessionToken> |
| AKS | SP:<tenantId>:<clientId> — tenant first |
Entra client secret (- for managed identity) |
| Generic / OKE | token, cert or kubeconfig |
bearer token, PEM/PKCS#12, or b64kubeconfig:<base64> |
| PRA Vault Token | PRA OAuth Client ID | PRA OAuth Client Secret |
In-cluster rotator RBAC
Applied automatically (k8s_ps_rotator_apply_rbac). LongLived needs serviceaccounts
get/list + secrets create/get/list/delete; Bound needs serviceaccounts/token create and
no access to Secrets at all. The ClusterRole always applies; the binding only when a
subject is configured, because the subject differs per cloud and is the single most common
onboarding mistake:
| Cloud | Binding subject | Config key |
|---|---|---|
| GKE | the service account's email | k8s_ps_rotator_gke_sa_email (blank → derived from the functional account) |
| AKS | the service principal's object id (oid), not the client id |
k8s_ps_rotator_aks_sp_object_id |
| EKS | the username the access entry maps the principal to | k8s_ps_rotator_eks_username |
| Generic / OKE | a bootstrap ServiceAccount | k8s_ps_rotator_bootstrap_sa / _namespace |
On EKS the access entry is created too when k8s_ps_rotator_eks_principal_arn is set —
without it the binding's User subject matches nothing and the API server returns 401,
which is invisible from inside the cluster. The dashboard never edits aws-auth; a bad edit
there can lock every principal out. If the ARN is unset the job result prints the command:
aws eks create-access-entry --cluster-name <cluster> \
--principal-arn <arn> --type STANDARD --username passwordsafe-rotator
The cluster's authentication mode must include API, or there is no access-entry API to
call. EKS defaults new clusters to CONFIG_MAP, where the command above is rejected and
the only way in is the aws-auth ConfigMap the dashboard deliberately never edits. Clusters
provisioned here are built API_AND_CONFIG_MAP (authentication_mode in
terraform/k8s_cluster/aws_eks); an older or hand-built cluster is converted in place, and
EKS allows the upgrade but never the reverse:
aws eks update-cluster-config --name <cluster> \
--access-config authenticationMode=API_AND_CONFIG_MAP
Symptom when this is missed: registration succeeds, then the first rotation fails with a
400 from Credentials/Change whose body is the plugin's attempt log ending in an API-server
401/403 — nothing in the cluster looks wrong, because the binding is fine and it is the
identity that was never mapped.
AKS has the same failure for a different reason: the ClusterRoleBinding is not what
authorises a Microsoft Entra principal there. Every cluster provisioned here sets
azure_rbac_enabled (terraform/k8s_cluster/azure_aks), which delegates Kubernetes API
authorisation to Azure role assignments. The role a rotator service principal is usually
given — Azure Kubernetes Service Cluster User Role — is control-plane only: it grants
listClusterUserCredential, so the identity fetches a kubeconfig and authenticates
perfectly, and holds no Kubernetes verb at all.
So the dashboard also creates the role assignment (k8s_ps_rotator_aks_assign_role, on by
default). It no longer needs to be told the object id. Three routes, tried in order:
k8s_ps_rotator_aks_sp_object_id, if you set it.- The dashboard's own token. An Azure functional account's username is the application (client) id, and an object id cannot be derived from one — but a token states the oid of the principal it was issued to. When the functional account's appid is the dashboard's own appid (one app registration doing both jobs, the usual case), the oid is already a claim in a token the dashboard mints anyway: no directory permission, no extra call. It is only used when the two appids match, because granting our own oid for a functional account that is a different app registration would succeed at ARM and change nothing about the 403.
- Microsoft Graph, when the functional account is a different principal. Needs
Application.Read.All(orDirectory.Read.All) consent, which most tenants have not granted, so a 403 here is skipped rather than fatal.
If all three come up empty the first rotation still fails — and that failure is itself
the fourth route. The AKS API server authenticated the functional account, resolved it
to an object id, and named that object id when it refused; so the dashboard reads the
principal out of the 403, makes the namespace-scoped assignment, waits for Azure to apply
it (k8s_ps_rotator_aks_propagation_seconds, default 240) and rotates again. A whole
registration therefore completes with no object id configured anywhere. Whatever the
resolved value was, it is written back to k8s_ps_rotator_aks_sp_object_id — announced in
the job result, and never over an operator's own value — so later registrations grant
before the first rotation rather than after it.
That recovery is the reason nothing in ps_k8s_token_service may call
ManagedAccounts/{id}/Credentials/Change directly; every rotation goes through
_rotate_token_once, and a static test enforces it. It backs off in three cases, all
deliberate: a non-Azure cluster, a failure that names no principal (a Password Safe
outage must not spend the propagation budget), and
k8s_ps_rotator_aks_assign_role=false. k8s_ps_rotator_aks_role picks which
one: writer (the default) and reader are the two Azure documents as assignable at
namespace scope, so those land on <cluster>/namespaces/<pra namespace>; admin,
clusteradmin or a custom role definition GUID go on the cluster. Writer is the right
default — it carries secrets/* and serviceaccounts/*, which covers LongLived's Secret
lifecycle and Bound's TokenRequest both. This needs Microsoft.Authorization/roleAssignments/write
(User Access Administrator or RBAC Administrator) on the cluster for the dashboard's own
Azure credential; when it is missing, or the object id is unset, the job result prints the
command:
az role assignment create --role "Azure Kubernetes Service RBAC Writer" \
--assignee-object-id <sp-object-id> --assignee-principal-type ServicePrincipal \
--scope <cluster-resource-id>/namespaces/pra-access
Use --assignee-object-id, not --assignee: the latter resolves the principal through
Microsoft Graph and fails outright ("Cannot find user or service principal in graph
database") for a service principal the operator cannot read. A new role assignment takes
up to five minutes to reach the authorisation server, and registration rotates immediately,
so a 403 in the minute after a first registration is worth one retry before it means
anything. Symptom when the assignment is missing — again at the first rotation, and it
names its own cause:
HTTP 403 (Forbidden): serviceaccounts "pra-access" is forbidden: User "<oid>" cannot get
resource "serviceaccounts" in API group "" in the namespace "pra-access": User does not
have access to the resource in Azure. Update role assignment to allow access.
That 403 is also where the object id comes from when it was never configured. A failed
k8s_ps_token job reads the principal out of the rotation failure and fills it into the
az role assignment create line it had already collected, so the remedy on the job page is
runnable as printed instead of carrying a <sp-object-id> placeholder that the operator has
to resolve through Microsoft Graph. Set k8s_ps_rotator_aks_sp_object_id to that value and
the next register makes the assignment itself. The id is matched off the surrounding prose,
never scanned for as a bare GUID: the same error body carries the subscription id (the
address is aks|<sub>|<rg>|<cluster>) and the plugin's own id.
Run Verify Functional Account in Password Safe after registering: it names every missing verb, prints the ClusterRole to apply, and logs the correct AKS object id on every run.
Keeping the PRA Vault copy in sync
Password Safe does it. Registration calls
POST ManagedAccounts/{id}/SyncedAccounts/{syncedAccountID} — {id} is the parent (the
Kubernetes Service Account Token account), {syncedAccountID} the subscriber (the PRA
Vault Token account). ps-cli synced-accounts create -ma-id <parent> -sa-id <subscriber> is
the same operation and is the quickest way to check it by hand. From then on every rotation of
the token account is applied to the subscriber too, which runs the PRA Vault Token plugin's
PATCH into PRA. Both accounts stay Password Safe-managed and audited, and no credential passes
through the dashboard at all.
GET …/ps-token/status re-reads the link from Password Safe on every open rather than caching
it, because an admin unlinking in the Password Safe console is exactly the condition an
operator opens that panel to diagnose. DELETE …/ps-token unlinks before off-boarding either
account.
Order of operations, and the repair when it was the other way round. The subscriber is
created only when the cluster already has a PRA Vault account to write into, so registering
the token before the PRA k8s tunnel leaves a cluster whose token rotates while nothing
carries the value into PRA — Synced Accounts on the parent reads 0 items and there is no
"PRA Vault Token" managed account in the tenant. Re-running the registration repairs it:
Repair registration in the cluster's token panel (or a re-POST of …/ps-token) creates the
subscriber, links the pair, and touches nothing in the cluster. The subscriber is created on a
placeholder rather than a checkout of the live token — the same 128-character cap drops any
real token — and Password Safe replaces it through the link, so PRA's copy is refreshed by
the next rotation. The pair is already in step at that point (the tunnel vaulted the value
Password Safe holds), so nothing is rotated to prove it; use Rotate now if you want the
path proven immediately, remembering that LongLived revokes the token a live session is using.
Removing the PRA tunnel does not unlink the pair. The plugin resolves its PRA Vault account by name (
k8s-<cluster>-sa), so re-provisioning the tunnel re-creates the account the link already points at and syncing resumes with no operator action. The cost is that a rotation landing while no tunnel exists fails the PRA half — visibly, in Password Safe's change log.
The OT demo cell uses the same primitive for its adminuser credential — parent on the
GCP VM SSH Rotation platform, subscriber on the PRA Vault Username Password plugin, PRA
Vault account associated to the cell's Jump Group for checkout/injection. See
OT demo cell.
The LongLived break window. Rotation revokes the old token immediately. Password Safe applies the new value to the subscriber as part of the same change, but change operations are queued, so there is a short window where PRA still holds the revoked token. Set the cluster's token mode to Bound on tunnels that must not break: Bound never revokes, and the old token stays valid until its TTL.
Why this works when the parent mints a JWT rather than accepting a password. Password Safe's published shared-credential behaviour describes ordinary password accounts, where it generates a password from the policy and pushes it outward. The sync actually copies whatever is stored as the parent's password, and the parent's plugin decides what that is: the "Kubernetes Service Account Token" plugin ignores the password policy — it is minting a JWT, not a password — and reports the token it got from the cluster, so the token is the stored credential and the token is what the subscriber receives.
Operator prerequisites
- Import both
.pspluginpackages and confirm the platform names. - Create the functional accounts above.
- Grant the API identity Requestor plus an access policy granting View on a Smart
Rule containing both managed accounts. There is no Smart Rule API, so this is out-of-band —
and it is the failure every Password Safe consumption path here hits first (
POST /Requests→4031/ 403). - Grant the API identity Password Safe Account Management (Full control) — what the
sync link needs, per the REST reference. (
ps-cli synced-accounts -hclaims Role Management (Read/Write) instead; that looks like an error in the CLI help, since the operation acts on managed accounts rather than roles. If the link 403s with Account Management already granted, try Role Management before assuming a different cause.) - Leave "Change Password After Release" OFF on both accounts. A credential change on either member of a synced pair re-rotates the pair, so with it on, every release of the PRA copy would rotate the real cluster token — an endless loop with a dead-credential window each time. There is no circuit breaker for this any more; the access policy is the fix.
- Give the Password Safe host or Resource Broker network reachability to the cluster's API server. For private clusters this is a real constraint — Password Safe does not route through the PRA Gateway.
Configuration keys — token rotation
| Key | Default | Notes |
|---|---|---|
k8s_ps_token_rotation_enabled |
false |
Master gate (row action, provision checkbox) |
k8s_ps_token_platform |
Kubernetes Service Account Token |
Plugin platform (name or id) |
k8s_ps_pravault_token_platform |
PRA Vault Token |
Subscriber plugin platform |
k8s_ps_functional_account_aws / k8s_ps_functional_account_azure / k8s_ps_functional_account_gcp / k8s_ps_functional_account_local |
— | Per cloud; _local also covers OKE and on-prem |
k8s_ps_pravault_functional_account |
— | PRA Config-API OAuth client for the PRA Vault account |
k8s_ps_workgroup |
— | Blank → passwordsafe_workgroup |
k8s_ps_token_mode |
longlived |
longlived (revokes) or bound (TTL expiry, no revoke) |
k8s_ps_token_ttl_seconds |
3600 |
Bound mode; clamped up to the API server's 600s floor |
k8s_ps_token_change_on_register |
true |
Rotate once on register — proves the whole path immediately |
k8s_ps_token_delete_legacy_secret |
true |
Retire the dashboard-minted Secret the plugin's sweep never touches |
k8s_ps_token_register_on_provision |
false |
Pre-tick the provision-form checkbox |
k8s_ps_pravault_mirror_enabled |
true |
Register and sync the PRA Vault Token account when a PRA vault account exists |
k8s_ps_token_checkout_duration_min |
15 |
Password Safe request duration for token reads |
k8s_ps_token_address_options |
— | Extra ;key=value appended to every address |
k8s_ps_rotator_apply_rbac |
true |
Apply the rotator ClusterRole + binding on register |
k8s_ps_rotator_gke_sa_email |
— | Blank → derived from the GCP functional account's name |
k8s_ps_rotator_aks_sp_object_id |
— | The oid claim. Resolved automatically when blank (own token → Graph → the first 403) and written back |
k8s_ps_rotator_aks_assign_role |
true |
Grant that oid an AKS data-plane role — with Azure RBAC the binding alone authorises nothing |
k8s_ps_rotator_aks_role |
writer |
reader/writer → namespace-scoped; admin/clusteradmin/GUID → cluster-scoped |
k8s_ps_rotator_aks_propagation_seconds |
240 |
How long a refused rotation waits for a NEW role assignment to apply before giving up |
k8s_ps_rotator_eks_username |
passwordsafe-rotator |
Access-entry username = the RBAC User subject |
k8s_ps_rotator_eks_principal_arn |
— | IAM principal behind the functional account's key |
k8s_ps_rotator_eks_create_access_entry |
true |
Create the access entry when the ARN is set |
k8s_ps_rotator_bootstrap_namespace / _sa |
beyondtrust / password-safe-rotator |
Generic-path bootstrap ServiceAccount |
The managed account name is <namespace>/<serviceaccount>, taken from pra_k8s_namespace /
pra_k8s_sa_name on the
Privileged Remote Access panel —
PRA owns that identity, Password Safe rotates its token.
Off-boarding removes both managed systems and the rotator RBAC; it runs automatically when the cluster is decommissioned or deregistered. Design rationale, including why each of these choices is what it is: k8s-sa-token-rotation.