This is an automated email from the ASF dual-hosted git repository.

bamaer pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new 9ea151d5f0 Issue #8302 : Add Kubernetes authentication to the 
HashiCorp Vault variable resolver (#8305)
9ea151d5f0 is described below

commit 9ea151d5f042f6dd34abc0ce51aa80082de3acc3
Author: Matt Casters <[email protected]>
AuthorDate: Fri Sep 11 13:21:10 2026 +0200

    Issue #8302 : Add Kubernetes authentication to the HashiCorp Vault variable 
resolver (#8305)
    
    * Issue #8302 : Add Kubernetes authentication to the HashiCorp Vault 
variable resolver
    
    * Issue #8302 : Address review feedback on Vault Kubernetes authentication
    
    Apply the 30s refresh margin for non-renewable tokens, retry Kubernetes
    auth once on 401/403, clear unused credentials on save, and parse auth
    types with Locale.ROOT.
---
 .../integration-tests/integration-tests-vault.yaml |  28 +
 .../resource/vault/k8s-tokenreview.py              | 186 ++++++
 ...lt-variable-resolver-authentication-tab-k8s.png | Bin 0 -> 85557 bytes
 ...-variable-resolver-authentication-tab-token.png | Bin 0 -> 59170 bytes
 .../vault-variable-resolver-connection-tab.png     | Bin 0 -> 99205 bytes
 .../vault-variable-resolver-secrets-tab.png        | Bin 0 -> 50486 bytes
 .../hashicorp-vault-variable-resolver.adoc         |  64 +-
 .../openbao-variable-resolver.adoc                 |  15 +-
 .../vault/0004-vault-resolve-secrets-k8s.hpl       | 134 ++++
 .../vault/main-0004-kubernetes-auth.hwf            | 132 ++++
 .../0004-vault-resolve-secrets-k8s UNIT.json       |  53 ++
 .../metadata/variable-resolver/vault-k8s.json      |  20 +
 plugins/tech/vault/pom.xml                         |  33 +
 .../resolver/vault/BaseVaultVariableResolver.java  | 694 ++++++++++++++++++---
 .../variables/resolver/vault/VaultAuthType.java    |  30 +
 .../vault/messages/messages_en_US.properties       |  12 +-
 .../vault/KubernetesServiceAccountJwt.java         |  81 +++
 .../resolver/vault/KubernetesTokenReviewMock.java  | 147 +++++
 .../resolver/vault/VaultKubernetesAuthIT.java      | 301 +++++++++
 .../resolver/vault/VaultVariableResolverTest.java  | 359 +++++++++++
 .../resolver/vault/VaultWidgetVisibilityTest.java  | 211 +++++++
 .../variables/resolver/VariableResolverEditor.java |   3 +
 22 files changed, 2403 insertions(+), 100 deletions(-)

diff --git a/docker/integration-tests/integration-tests-vault.yaml 
b/docker/integration-tests/integration-tests-vault.yaml
index 6af5ae6e11..acd8f5821f 100644
--- a/docker/integration-tests/integration-tests-vault.yaml
+++ b/docker/integration-tests/integration-tests-vault.yaml
@@ -15,6 +15,9 @@
 # specific language governing permissions and limitations
 # under the License.
 
+volumes:
+  k8s-jwt:
+
 services:
   integration_test_vault:
     extends:
@@ -23,8 +26,12 @@ services:
     depends_on:
       vault:
         condition: service_healthy
+      k8s-mock:
+        condition: service_healthy
     links:
       - vault
+    volumes:
+      - k8s-jwt:/tmp/k8s-sa:ro
   vault:
     image: hashicorp/vault
     ports:
@@ -40,3 +47,24 @@ services:
       timeout: 3s
       retries: 10
       start_period: 5s
+  k8s-mock:
+    image: python:3.12-alpine
+    depends_on:
+      vault:
+        condition: service_healthy
+    environment:
+      JWT_PATH: /jwt/token
+      PORT: "8080"
+      VAULT_ADDR: http://vault:8200
+      VAULT_TOKEN: myroot
+      KUBERNETES_HOST: http://k8s-mock:8080
+    volumes:
+      - ./resource/vault/k8s-tokenreview.py:/k8s-tokenreview.py:ro
+      - k8s-jwt:/jwt
+    command: [ "python", "/k8s-tokenreview.py" ]
+    healthcheck:
+      test: [ "CMD", "python", "-c", "import urllib.request; 
urllib.request.urlopen('http://127.0.0.1:8080/ready')" ]
+      interval: 2s
+      timeout: 2s
+      retries: 20
+      start_period: 2s
diff --git a/docker/integration-tests/resource/vault/k8s-tokenreview.py 
b/docker/integration-tests/resource/vault/k8s-tokenreview.py
new file mode 100644
index 0000000000..5fccbb7898
--- /dev/null
+++ b/docker/integration-tests/resource/vault/k8s-tokenreview.py
@@ -0,0 +1,186 @@
+#!/usr/bin/env python3
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+"""Minimal Kubernetes TokenReview API for Vault Kubernetes auth integration 
tests.
+
+Also enables Vault Kubernetes auth against this mock. The Hop docker suite uses
+``--abort-on-container-exit``, so this process must keep running after setup.
+"""
+
+from __future__ import annotations
+
+import base64
+import json
+import os
+import threading
+import time
+import urllib.error
+import urllib.request
+from http.server import BaseHTTPRequestHandler, HTTPServer
+
+
+NAMESPACE = "default"
+SA_NAME = "hop"
+JWT_PATH = os.environ.get("JWT_PATH", "/jwt/token")
+PORT = int(os.environ.get("PORT", "8080"))
+VAULT_ADDR = os.environ.get("VAULT_ADDR", "http://vault:8200";).rstrip("/")
+VAULT_TOKEN = os.environ.get("VAULT_TOKEN", "myroot")
+KUBERNETES_HOST = os.environ.get("KUBERNETES_HOST", "http://k8s-mock:8080";)
+# RS256 JWT for SA default/hop. Vault does not verify the signature when no
+# public keys are configured, but it does require an RSA/ECDSA algorithm.
+HOP_SA_JWT = (
+    "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9."
+    
"eyJpc3MiOiJrdWJlcm5ldGVzL3NlcnZpY2VhY2NvdW50Iiwia3ViZXJuZXRlcy5pby9zZXJ2aWNlYWNjb3VudC9uYW1lc3BhY2UiOiJkZWZhdWx0Iiwia3ViZXJuZXRlcy5pby9zZXJ2aWNlYWNjb3VudC9zZWNyZXQubmFtZSI6ImhvcC10b2tlbiIsImt1YmVybmV0ZXMuaW8vc2VydmljZWFjY291bnQvc2VydmljZS1hY2NvdW50Lm5hbWUiOiJob3AiLCJrdWJlcm5ldGVzLmlvL3NlcnZpY2VhY2NvdW50L3NlcnZpY2UtYWNjb3VudC51aWQiOiIxIiwic3ViIjoic3lzdGVtOnNlcnZpY2VhY2NvdW50OmRlZmF1bHQ6aG9wIn0."
+    
"diHYA5MSwzDlpHZo6wuPsndgG-TrsGOHr87IDzfqq9-pZG21iM1bCsHt0i1liioEHe7F4XQLlXziDBHLGWKcj_PGjiP4K9FdkOCBjmyJF6hTNN0OrgHzLCKK3RBDVTXVD5xg-kCAhCeIoebg1eCuYTf6r8uiiodKSS8k1ImSdnOjM5E3TMBQpCYdnOIEmUw0uZq9Z1ome2XVrV9h173wp5BHI_fTjJpj7jVwDKhoP8RC1OWMtKsvYKMlkH4sXJqeBm60BuOT1GjLOJ9k0IkOJacLxYlyM1pz73j3J8LaD91_cAeTpyzg6MImCjdPI35pmuhzML4cTubrd097KMWZDg"
+)
+
+ready = False
+
+
+def jwt_subject(token: str) -> str:
+    try:
+        payload_b64 = token.split(".")[1]
+        payload_b64 += "=" * (-len(payload_b64) % 4)
+        payload = json.loads(base64.urlsafe_b64decode(payload_b64))
+        return str(payload.get("sub", ""))
+    except Exception:
+        return ""
+
+
+def write_jwt() -> None:
+    directory = os.path.dirname(JWT_PATH)
+    if directory:
+        os.makedirs(directory, exist_ok=True)
+    with open(JWT_PATH, "w", encoding="utf-8") as handle:
+        handle.write(HOP_SA_JWT)
+
+
+def vault_write(path: str, payload: dict) -> None:
+    data = json.dumps(payload).encode("utf-8")
+    request = urllib.request.Request(
+        VAULT_ADDR + path,
+        data=data,
+        headers={
+            "X-Vault-Token": VAULT_TOKEN,
+            "Content-Type": "application/json",
+        },
+        method="POST",
+    )
+    try:
+        urllib.request.urlopen(request, timeout=10)
+    except urllib.error.HTTPError as error:
+        body = error.read().decode("utf-8", errors="replace")
+        if error.code == 400 and "already in use" in body:
+            return
+        raise RuntimeError(f"Vault {path} returned {error.code}: {body}") from 
error
+
+
+def wait_for_vault() -> None:
+    last_error = None
+    for _ in range(30):
+        try:
+            urllib.request.urlopen(VAULT_ADDR + "/v1/sys/health", timeout=3)
+            return
+        except Exception as error:  # noqa: BLE001
+            last_error = error
+            time.sleep(1)
+    raise RuntimeError(f"Vault at {VAULT_ADDR} did not become ready: 
{last_error}")
+
+
+def configure_vault() -> None:
+    wait_for_vault()
+    vault_write("/v1/sys/auth/kubernetes", {"type": "kubernetes"})
+    vault_write(
+        "/v1/auth/kubernetes/config",
+        {
+            "kubernetes_host": KUBERNETES_HOST,
+            "disable_iss_validation": True,
+            "disable_local_ca_jwt": True,
+            "token_reviewer_jwt": "dummy",
+        },
+    )
+    vault_write(
+        "/v1/sys/policies/acl/hop-read",
+        {"policy": 'path "secret/data/*" { capabilities = ["read"] }'},
+    )
+    vault_write(
+        "/v1/auth/kubernetes/role/hop",
+        {
+            "bound_service_account_names": SA_NAME,
+            "bound_service_account_namespaces": NAMESPACE,
+            "policies": ["hop-read"],
+            "ttl": "1h",
+        },
+    )
+
+
+class Handler(BaseHTTPRequestHandler):
+    def do_GET(self) -> None:  # noqa: N802
+        if self.path.startswith("/ready"):
+            if ready:
+                self._send(200, b"ok", "text/plain")
+            else:
+                self._send(503, b"not ready", "text/plain")
+            return
+        if self.path.startswith("/healthz"):
+            self._send(200, b"ok", "text/plain")
+            return
+        self._send(200, b'{"kind":"APIVersions","versions":["v1"]}', 
"application/json")
+
+    def do_POST(self) -> None:  # noqa: N802
+        length = int(self.headers.get("Content-Length", "0"))
+        body = self.rfile.read(length) if length else b"{}"
+        token = ""
+        try:
+            token = json.loads(body.decode("utf-8")).get("spec", 
{}).get("token", "")
+        except Exception:
+            token = ""
+        sub = jwt_subject(token)
+        authenticated = sub.startswith("system:serviceaccount:")
+        review = {
+            "apiVersion": "authentication.k8s.io/v1",
+            "kind": "TokenReview",
+            "status": {
+                "authenticated": authenticated,
+                "user": {
+                    "username": sub,
+                    "uid": "1",
+                    "groups": ["system:serviceaccounts"],
+                },
+            },
+        }
+        self._send(200, json.dumps(review).encode("utf-8"), "application/json")
+
+    def log_message(self, format: str, *args) -> None:  # noqa: A003
+        return
+
+    def _send(self, status: int, body: bytes, content_type: str) -> None:
+        self.send_response(status)
+        self.send_header("Content-Type", content_type)
+        self.send_header("Content-Length", str(len(body)))
+        self.end_headers()
+        self.wfile.write(body)
+
+
+if __name__ == "__main__":
+    write_jwt()
+    server = HTTPServer(("0.0.0.0", PORT), Handler)
+    thread = threading.Thread(target=server.serve_forever, daemon=True)
+    thread.start()
+    configure_vault()
+    ready = True
+    thread.join()
diff --git 
a/docs/hop-user-manual/modules/ROOT/assets/images/metadata-types/variable-resolver/vault-variable-resolver-authentication-tab-k8s.png
 
b/docs/hop-user-manual/modules/ROOT/assets/images/metadata-types/variable-resolver/vault-variable-resolver-authentication-tab-k8s.png
new file mode 100644
index 0000000000..5f1767a506
Binary files /dev/null and 
b/docs/hop-user-manual/modules/ROOT/assets/images/metadata-types/variable-resolver/vault-variable-resolver-authentication-tab-k8s.png
 differ
diff --git 
a/docs/hop-user-manual/modules/ROOT/assets/images/metadata-types/variable-resolver/vault-variable-resolver-authentication-tab-token.png
 
b/docs/hop-user-manual/modules/ROOT/assets/images/metadata-types/variable-resolver/vault-variable-resolver-authentication-tab-token.png
new file mode 100644
index 0000000000..6904a9287d
Binary files /dev/null and 
b/docs/hop-user-manual/modules/ROOT/assets/images/metadata-types/variable-resolver/vault-variable-resolver-authentication-tab-token.png
 differ
diff --git 
a/docs/hop-user-manual/modules/ROOT/assets/images/metadata-types/variable-resolver/vault-variable-resolver-connection-tab.png
 
b/docs/hop-user-manual/modules/ROOT/assets/images/metadata-types/variable-resolver/vault-variable-resolver-connection-tab.png
new file mode 100644
index 0000000000..02bee8ab1a
Binary files /dev/null and 
b/docs/hop-user-manual/modules/ROOT/assets/images/metadata-types/variable-resolver/vault-variable-resolver-connection-tab.png
 differ
diff --git 
a/docs/hop-user-manual/modules/ROOT/assets/images/metadata-types/variable-resolver/vault-variable-resolver-secrets-tab.png
 
b/docs/hop-user-manual/modules/ROOT/assets/images/metadata-types/variable-resolver/vault-variable-resolver-secrets-tab.png
new file mode 100644
index 0000000000..ae9875e02b
Binary files /dev/null and 
b/docs/hop-user-manual/modules/ROOT/assets/images/metadata-types/variable-resolver/vault-variable-resolver-secrets-tab.png
 differ
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/variable-resolver/hashicorp-vault-variable-resolver.adoc
 
b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/variable-resolver/hashicorp-vault-variable-resolver.adoc
index 4e8948b83e..f85e86cf87 100644
--- 
a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/variable-resolver/hashicorp-vault-variable-resolver.adoc
+++ 
b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/variable-resolver/hashicorp-vault-variable-resolver.adoc
@@ -23,11 +23,43 @@ under the License.
 == Functionality
 
 This variable resolver can retrieve secrets from a 
https://www.vaultproject.io/[Hashicorp Vault].
-Here are the options to use:
+The editor groups the options into **Connection**, **Authentication** and 
**Secrets**. The Authentication tab only shows the fields that belong to the 
authentication type you picked.
+
+=== Connection
+
+image::metadata-types/variable-resolver/vault-variable-resolver-connection-tab.png[Connection
 tab of the HashiCorp Vault variable resolver]
 
 * Vault address: The base address and port of the Vault server (for example: 
https://vault-server:8200)
-* Vault token: The token to use to authenticate
 * Namespace: The Vault namespace to use (optional, primarily used in 
enterprise/multi-tenant setups)
+* Validate HTTPS connections?: It's recommended to enable connection 
validation in production. This secures the connection with the X.509 
certificate specified in one of either next option.
+* PEM file path: The name of the file (VFS) containing the X.509 certificate 
string
+* PEM string: The X.509 string itself in case you're not using a file
+* Open connection timeout: The connection timeout when getting a http(s) 
connection in milliseconds.
+* Read timeout: The timeout in milliseconds when reading.
+
+=== Authentication
+
+How Hop proves its identity to Vault. `TOKEN` is the default and keeps 
existing resolvers working. `KUBERNETES` is intended for Hop Server (or Hop 
Gui) running in a Kubernetes pod.
+
+When the authentication type is `TOKEN`, only the Vault token field is shown:
+
+image::metadata-types/variable-resolver/vault-variable-resolver-authentication-tab-token.png[Authentication
 tab with type TOKEN]
+
+* Vault token: The token to use to authenticate.
+
+When the authentication type is `KUBERNETES`, the token field is replaced by 
the Kubernetes options:
+
+image::metadata-types/variable-resolver/vault-variable-resolver-authentication-tab-k8s.png[Authentication
 tab with type KUBERNETES]
+
+* Kubernetes role: The Vault Kubernetes auth role bound to this workload's 
ServiceAccount.
+* Kubernetes JWT file path: File that holds the ServiceAccount JWT. Defaults 
to `/var/run/secrets/kubernetes.io/serviceaccount/token` when left empty.
+* Kubernetes JWT: Optional JWT string used instead of the file when set (a 
variable such as `'${KUBERNETES_JWT}'` is typical).
+* Kubernetes auth mount path: Vault auth mount without the leading `auth/`. 
Defaults to `kubernetes`.
+
+=== Secrets
+
+image::metadata-types/variable-resolver/vault-variable-resolver-secrets-tab.png[Secrets
 tab of the HashiCorp Vault variable resolver]
+
 * Path prefix: an optional path prefix which gets added before the key paths 
in the resolver expressions.  For example, if you put `kv-other/data` in here, 
expression
 
 [source]
@@ -42,13 +74,6 @@ will resolve internally to:
 #{vault:kv-other/data/db:password}
 ----
 
-
-* Validate HTTPS connections?: It's recommended to enable connection 
validation in production. This secures the connection with the X.509 
certificate specified in one of either next option.
-* PEM file path: The name of the file (VFS) containing the X.509 certificate 
string
-* PEM string: The X.509 string itself in case you're not using a file
-* Open connection timeout: The connection timeout when getting a http(s) 
connection in milliseconds.
-* Read connection timeout: The timeout in milliseconds when reading.
-
 The variable expression you can resolve with this plugin type is (as always) 
in the following format:
 
 `{openvar}name:path-key:value-key{closevar}`
@@ -63,7 +88,7 @@ In case we don't specify a `value-key`, you will give back 
the complete JSON str
 
 Suppose we have a secret defined in the Vault, in a KV secrets engine:
 
-image:metadata-types/variable-resolver/vault-variable-resolver-server.png
+image::metadata-types/variable-resolver/vault-variable-resolver-server.png[Secret
 stored in a HashiCorp Vault KV engine]
 
 We can define a connection called `vault` and retrieve values with expressions:
 
@@ -71,3 +96,22 @@ We can define a connection called `vault` and retrieve 
values with expressions:
 * `{openvar}vault:hop/data/some-db:username{closevar}` : john
 * `{openvar}vault:hop/data/some-db{closevar}` : 
`{"db":"test","hostname":"localhost","password":"some-password","port":"3306","username":"john"}`
 
+== Kubernetes authentication
+
+When Hop runs inside a Kubernetes pod, prefer `KUBERNETES` over a long-lived 
Vault token. Hop reads the pod ServiceAccount JWT, logs in to Vault's 
Kubernetes auth backend, and uses the short-lived client token Vault returns. 
That token is kept in memory, renewed when Vault says it is renewable, and 
replaced by a fresh login when it expires. It is never stored in the resolver 
metadata.
+
+On the Vault side, enable Kubernetes auth and bind a role to the 
ServiceAccount Hop runs as:
+
+[source,bash]
+----
+vault auth enable kubernetes
+vault write auth/kubernetes/config 
kubernetes_host=https://$KUBERNETES_SERVICE_HOST:$KUBERNETES_SERVICE_PORT
+vault write auth/kubernetes/role/hop \
+    bound_service_account_names=hop \
+    bound_service_account_namespaces=hop \
+    policies=hop \
+    ttl=1h
+----
+
+In the variable resolver metadata, set Authentication type to `KUBERNETES` on 
the Authentication tab, Kubernetes role to `hop`, and Vault address on the 
Connection tab to the in-cluster Vault URL. Leave the JWT file path empty to 
use the token Kubernetes mounts at 
`/var/run/secrets/kubernetes.io/serviceaccount/token`.
+
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/variable-resolver/openbao-variable-resolver.adoc
 
b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/variable-resolver/openbao-variable-resolver.adoc
index 4ece79afce..d304df0237 100644
--- 
a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/variable-resolver/openbao-variable-resolver.adoc
+++ 
b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/variable-resolver/openbao-variable-resolver.adoc
@@ -21,10 +21,17 @@ limitations under the License.
 == Functionality
 
 This variable resolver can retrieve secrets from an 
https://openbao.org/[OpenBAO] instance.
+The editor is the same as the 
xref:metadata-types/variable-resolver/hashicorp-vault-variable-resolver.adoc[HashiCorp
 Vault variable resolver]: options are grouped into **Connection**, 
**Authentication** and **Secrets**, and the Authentication tab only shows the 
fields that belong to the authentication type you picked.
+
 Here are the options to use:
 
 * OpenBAO address: The base address and port of the OpenBAO server (for 
example: https://openbao-server:8200)
-* OpenBAO token: The token to use to authenticate
+* Authentication type: How Hop proves its identity to OpenBAO. `TOKEN` is the 
default and keeps existing resolvers working. `KUBERNETES` is intended for Hop 
Server (or Hop Gui) running in a Kubernetes pod.
+* OpenBAO token: The token to use to authenticate. Shown only when the 
authentication type is `TOKEN`.
+* Kubernetes role: The OpenBAO Kubernetes auth role bound to this workload's 
ServiceAccount. Shown only when the authentication type is `KUBERNETES`.
+* Kubernetes JWT file path: File that holds the ServiceAccount JWT. Defaults 
to `/var/run/secrets/kubernetes.io/serviceaccount/token` when left empty. Shown 
only for `KUBERNETES`.
+* Kubernetes JWT: Optional JWT string used instead of the file when set (a 
variable such as `'${KUBERNETES_JWT}'` is typical). Shown only for `KUBERNETES`.
+* Kubernetes auth mount path: OpenBAO auth mount without the leading `auth/`. 
Defaults to `kubernetes`. Shown only for `KUBERNETES`.
 * Namespace: The namespace to use (optional, primarily used in 
enterprise/multi-tenant setups)
 * Path prefix: an optional path prefix which gets added before the key paths 
in the resolver expressions.  For example, if you put `kv-other/data` in here, 
expression
 
@@ -64,3 +71,9 @@ We can define a connection called `openbao` and retrieve 
values with expressions
 * `{openvar}openbao:hop/data/some-db:hostname{closevar}` : localhost
 * `{openvar}openbao:hop/data/some-db:username{closevar}` : john
 * `{openvar}openbao:hop/data/some-db{closevar}` : 
`{"db":"test","hostname":"localhost","password":"some-password","port":"3306","username":"john"}`
+
+== Kubernetes authentication
+
+When Hop runs inside a Kubernetes pod, prefer `KUBERNETES` over a long-lived 
OpenBAO token. Hop reads the pod ServiceAccount JWT, logs in to OpenBAO's 
Kubernetes auth backend, and uses the short-lived client token OpenBAO returns. 
That token is kept in memory, renewed when OpenBAO says it is renewable, and 
replaced by a fresh login when it expires. It is never stored in the resolver 
metadata.
+
+On the OpenBAO side, enable Kubernetes auth and bind a role to the 
ServiceAccount Hop runs as, then set Authentication type to `KUBERNETES` and 
Kubernetes role to that role name. Leave the JWT file path empty to use the 
token Kubernetes mounts at 
`/var/run/secrets/kubernetes.io/serviceaccount/token`.
diff --git a/integration-tests/vault/0004-vault-resolve-secrets-k8s.hpl 
b/integration-tests/vault/0004-vault-resolve-secrets-k8s.hpl
new file mode 100644
index 0000000000..74746cf19b
--- /dev/null
+++ b/integration-tests/vault/0004-vault-resolve-secrets-k8s.hpl
@@ -0,0 +1,134 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<pipeline>
+  <info>
+    <name>0004-vault-resolve-secrets-k8s</name>
+    <name_sync_with_filename>Y</name_sync_with_filename>
+    <description/>
+    <extended_description/>
+    <pipeline_version/>
+    <pipeline_type>Normal</pipeline_type>
+    <parameters>
+    </parameters>
+    <capture_transform_performance>N</capture_transform_performance>
+    
<transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+    
<transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+    <created_user>-</created_user>
+    <created_date>2025/01/14 15:54:11.134</created_date>
+    <modified_user>-</modified_user>
+    <modified_date>2025/01/14 15:54:11.134</modified_date>
+  </info>
+  <notepads>
+  </notepads>
+  <order>
+    <hop>
+      <from>Get variables</from>
+      <to>Output</to>
+      <enabled>Y</enabled>
+    </hop>
+  </order>
+  <transform>
+    <name>Get variables</name>
+    <type>GetVariable</type>
+    <description/>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <fields>
+      <field>
+        <length>-1</length>
+        <name>json-data</name>
+        <precision>-1</precision>
+        <trim_type>none</trim_type>
+        <type>String</type>
+        <variable>#{vault-k8s:secret/data/hop}</variable>
+      </field>
+      <field>
+        <length>-1</length>
+        <name>hostname</name>
+        <precision>-1</precision>
+        <trim_type>none</trim_type>
+        <type>String</type>
+        <variable>#{vault-k8s:secret/data/hop:hostname}</variable>
+      </field>
+      <field>
+        <length>-1</length>
+        <name>port</name>
+        <precision>-1</precision>
+        <trim_type>none</trim_type>
+        <type>String</type>
+        <variable>#{vault-k8s:secret/data/hop:port}</variable>
+      </field>
+      <field>
+        <length>-1</length>
+        <name>db</name>
+        <precision>-1</precision>
+        <trim_type>none</trim_type>
+        <type>String</type>
+        <variable>#{vault-k8s:secret/data/hop:db}</variable>
+      </field>
+      <field>
+        <length>-1</length>
+        <name>username</name>
+        <precision>-1</precision>
+        <trim_type>none</trim_type>
+        <type>String</type>
+        <variable>#{vault-k8s:secret/data/hop:username}</variable>
+      </field>
+      <field>
+        <length>-1</length>
+        <name>password</name>
+        <precision>-1</precision>
+        <trim_type>none</trim_type>
+        <type>String</type>
+        <variable>#{vault-k8s:secret/data/hop:password}</variable>
+      </field>
+    </fields>
+    <attributes/>
+    <GUI>
+      <xloc>64</xloc>
+      <yloc>48</yloc>
+    </GUI>
+  </transform>
+  <transform>
+    <name>Output</name>
+    <type>Dummy</type>
+    <description/>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <attributes/>
+    <GUI>
+      <xloc>288</xloc>
+      <yloc>48</yloc>
+    </GUI>
+  </transform>
+  <transform_error_handling>
+  </transform_error_handling>
+  <attributes/>
+</pipeline>
diff --git a/integration-tests/vault/main-0004-kubernetes-auth.hwf 
b/integration-tests/vault/main-0004-kubernetes-auth.hwf
new file mode 100644
index 0000000000..cb249a2625
--- /dev/null
+++ b/integration-tests/vault/main-0004-kubernetes-auth.hwf
@@ -0,0 +1,132 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<workflow>
+  <name>main-0004-kubernetes-auth</name>
+  <name_sync_with_filename>Y</name_sync_with_filename>
+  <description/>
+  <extended_description/>
+  <workflow_version/>
+  <created_user>-</created_user>
+  <created_date>2025/01/14 15:46:57.125</created_date>
+  <modified_user>-</modified_user>
+  <modified_date>2025/01/14 15:46:57.125</modified_date>
+  <parameters>
+    </parameters>
+  <actions>
+    <action>
+      <name>Start</name>
+      <description/>
+      <type>SPECIAL</type>
+      <attributes/>
+      <DayOfMonth>1</DayOfMonth>
+      <doNotWaitOnFirstExecution>N</doNotWaitOnFirstExecution>
+      <hour>12</hour>
+      <intervalMinutes>60</intervalMinutes>
+      <intervalSeconds>0</intervalSeconds>
+      <minutes>0</minutes>
+      <repeat>N</repeat>
+      <schedulerType>0</schedulerType>
+      <weekDay>1</weekDay>
+      <parallel>N</parallel>
+      <xloc>96</xloc>
+      <yloc>80</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>0001-vault-add-secrets.hpl</name>
+      <description/>
+      <type>PIPELINE</type>
+      <attributes/>
+      <add_date>N</add_date>
+      <add_time>N</add_time>
+      <clear_files>N</clear_files>
+      <clear_rows>N</clear_rows>
+      <create_parent_folder>N</create_parent_folder>
+      <exec_per_row>N</exec_per_row>
+      <filename>${PROJECT_HOME}/0001-vault-add-secrets.hpl</filename>
+      <loglevel>Basic</loglevel>
+      <parameters>
+        <pass_all_parameters>Y</pass_all_parameters>
+      </parameters>
+      <params_from_previous>N</params_from_previous>
+      <run_configuration>local</run_configuration>
+      <set_append_logfile>N</set_append_logfile>
+      <set_logfile>N</set_logfile>
+      <wait_until_finished>Y</wait_until_finished>
+      <parallel>N</parallel>
+      <xloc>256</xloc>
+      <yloc>80</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>Run Pipeline Unit Tests</name>
+      <description/>
+      <type>RunPipelineTests</type>
+      <attributes/>
+      <test_names>
+        <test_name>
+          <name>0004-vault-resolve-secrets-k8s UNIT</name>
+        </test_name>
+      </test_names>
+      <parallel>N</parallel>
+      <xloc>416</xloc>
+      <yloc>80</yloc>
+      <attributes_hac/>
+    </action>
+  </actions>
+  <hops>
+    <hop>
+      <from>Start</from>
+      <to>0001-vault-add-secrets.hpl</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>Y</unconditional>
+    </hop>
+    <hop>
+      <from>0001-vault-add-secrets.hpl</from>
+      <to>Run Pipeline Unit Tests</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+  </hops>
+  <notepads>
+    <notepad>
+      <backgroundcolorblue>251</backgroundcolorblue>
+      <backgroundcolorgreen>232</backgroundcolorgreen>
+      <backgroundcolorred>201</backgroundcolorred>
+      <bordercolorblue>90</bordercolorblue>
+      <bordercolorgreen>58</bordercolorgreen>
+      <bordercolorred>14</bordercolorred>
+      <fontbold>N</fontbold>
+      <fontcolorblue>90</fontcolorblue>
+      <fontcolorgreen>58</fontcolorgreen>
+      <fontcolorred>14</fontcolorred>
+      <fontitalic>N</fontitalic>
+      <fontsize>-1</fontsize>
+      <height>32</height>
+      <xloc>119</xloc>
+      <yloc>196</yloc>
+      <note>VAULT_ADDR='http://0.0.0.0:8200'</note>
+      <width>32</width>
+    </notepad>
+  </notepads>
+  <attributes/>
+</workflow>
diff --git 
a/integration-tests/vault/metadata/unit-test/0004-vault-resolve-secrets-k8s 
UNIT.json 
b/integration-tests/vault/metadata/unit-test/0004-vault-resolve-secrets-k8s 
UNIT.json
new file mode 100644
index 0000000000..8dea0b3eef
--- /dev/null
+++ b/integration-tests/vault/metadata/unit-test/0004-vault-resolve-secrets-k8s 
UNIT.json       
@@ -0,0 +1,53 @@
+{
+  "database_replacements": [],
+  "autoOpening": true,
+  "description": "",
+  "persist_filename": "",
+  "test_type": "UNIT_TEST",
+  "variableValues": [],
+  "basePath": "",
+  "golden_data_sets": [
+    {
+      "field_mappings": [
+        {
+          "transform_field": "json-data",
+          "data_set_field": "json-data"
+        },
+        {
+          "transform_field": "hostname",
+          "data_set_field": "hostname"
+        },
+        {
+          "transform_field": "port",
+          "data_set_field": "port"
+        },
+        {
+          "transform_field": "db",
+          "data_set_field": "db"
+        },
+        {
+          "transform_field": "username",
+          "data_set_field": "username"
+        },
+        {
+          "transform_field": "password",
+          "data_set_field": "password"
+        }
+      ],
+      "field_order": [
+        "json-data",
+        "hostname",
+        "port",
+        "db",
+        "username",
+        "password"
+      ],
+      "data_set_name": "0001-golden-resolved-variables",
+      "transform_name": "Output"
+    }
+  ],
+  "input_data_sets": [],
+  "name": "0004-vault-resolve-secrets-k8s UNIT",
+  "trans_test_tweaks": [],
+  "pipeline_filename": "./0004-vault-resolve-secrets-k8s.hpl"
+}
\ No newline at end of file
diff --git a/integration-tests/vault/metadata/variable-resolver/vault-k8s.json 
b/integration-tests/vault/metadata/variable-resolver/vault-k8s.json
new file mode 100644
index 0000000000..6f78305be9
--- /dev/null
+++ b/integration-tests/vault/metadata/variable-resolver/vault-k8s.json
@@ -0,0 +1,20 @@
+{
+  "virtualPath": "",
+  "name": "vault-k8s",
+  "description": "Hashicorp Vault Kubernetes auth",
+  "variable-resolver": {
+    "Vault-Variable-Resolver": {
+      "vaultAddress": "http://vault:8200";,
+      "pemString": "",
+      "openTimeout": "",
+      "verifyingSsl": false,
+      "pemFilePath": "",
+      "readTimeout": "",
+      "authenticationType": "KUBERNETES",
+      "kubernetesRole": "hop",
+      "kubernetesJwtPath": "/tmp/k8s-sa/token",
+      "kubernetesJwt": "",
+      "kubernetesAuthPath": "kubernetes"
+    }
+  }
+}
diff --git a/plugins/tech/vault/pom.xml b/plugins/tech/vault/pom.xml
index 0e9f02ffd9..fce6b048ac 100755
--- a/plugins/tech/vault/pom.xml
+++ b/plugins/tech/vault/pom.xml
@@ -29,6 +29,7 @@
     <name>Hop Plugins Technology Hashicorp Vault</name>
 
     <properties>
+        <testcontainers.version>1.21.4</testcontainers.version>
         <vault.driver.version>6.2.2</vault.driver.version>
     </properties>
 
@@ -38,5 +39,37 @@
             <artifactId>vault-java-driver</artifactId>
             <version>${vault.driver.version}</version>
         </dependency>
+        <dependency>
+            <groupId>org.testcontainers</groupId>
+            <artifactId>junit-jupiter</artifactId>
+            <version>${testcontainers.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.testcontainers</groupId>
+            <artifactId>testcontainers</artifactId>
+            <version>${testcontainers.version}</version>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
+
+    <build>
+        <plugins>
+            <!-- The *IT tests start a Vault server in a container. They are 
skipped automatically
+                 when no Docker daemon is available, so a build without Docker 
is unaffected.
+                 Use -DskipITs to skip them explicitly. -->
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-failsafe-plugin</artifactId>
+                <executions>
+                    <execution>
+                        <goals>
+                            <goal>integration-test</goal>
+                            <goal>verify</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+    </build>
 </project>
diff --git 
a/plugins/tech/vault/src/main/java/org/apache/hop/core/variables/resolver/vault/BaseVaultVariableResolver.java
 
b/plugins/tech/vault/src/main/java/org/apache/hop/core/variables/resolver/vault/BaseVaultVariableResolver.java
index 02d28c1794..fe9ac7e52a 100644
--- 
a/plugins/tech/vault/src/main/java/org/apache/hop/core/variables/resolver/vault/BaseVaultVariableResolver.java
+++ 
b/plugins/tech/vault/src/main/java/org/apache/hop/core/variables/resolver/vault/BaseVaultVariableResolver.java
@@ -21,12 +21,19 @@ package org.apache.hop.core.variables.resolver.vault;
 import io.github.jopenlibs.vault.SslConfig;
 import io.github.jopenlibs.vault.Vault;
 import io.github.jopenlibs.vault.VaultConfig;
+import io.github.jopenlibs.vault.VaultException;
+import io.github.jopenlibs.vault.response.AuthResponse;
 import io.github.jopenlibs.vault.response.LogicalResponse;
 import java.io.BufferedReader;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.InputStreamReader;
 import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
 import lombok.Getter;
 import lombok.Setter;
 import org.apache.commons.lang3.StringUtils;
@@ -34,125 +41,453 @@ import org.apache.hop.core.Const;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.gui.plugin.GuiElementType;
 import org.apache.hop.core.gui.plugin.GuiWidgetElement;
+import org.apache.hop.core.gui.plugin.GuiWidgetGroupType;
+import org.apache.hop.core.logging.ILogChannel;
 import org.apache.hop.core.logging.LogChannel;
 import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.core.variables.resolver.IVariableResolver;
 import org.apache.hop.core.variables.resolver.VariableResolver;
 import org.apache.hop.core.vfs.HopVfs;
 import org.apache.hop.metadata.api.HopMetadataProperty;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.ui.core.gui.GuiCompositeWidgets;
+import org.apache.hop.ui.core.gui.IGuiPluginCompositeWidgetsListener;
+import org.apache.hop.ui.core.widget.ComboVar;
+import org.apache.hop.ui.core.widget.TextVar;
+import org.eclipse.swt.widgets.Combo;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Text;
 
 @Getter
 @Setter
-public abstract class BaseVaultVariableResolver implements IVariableResolver {
+public abstract class BaseVaultVariableResolver
+    implements IVariableResolver, IGuiPluginCompositeWidgetsListener {
+
+  static final String ID_VAULT_ADDRESS = "vaultAddress";
+  static final String ID_AUTHENTICATION_TYPE = "authenticationType";
+  static final String ID_VAULT_TOKEN = "vaultToken";
+  static final String ID_KUBERNETES_ROLE = "kubernetesRole";
+  static final String ID_KUBERNETES_JWT_PATH = "kubernetesJwtPath";
+  static final String ID_KUBERNETES_JWT = "kubernetesJwt";
+  static final String ID_KUBERNETES_AUTH_PATH = "kubernetesAuthPath";
+  static final String ID_PATH_PREFIX = "pathPrefix";
+  static final String ID_NAMESPACE = "namespace";
+  static final String ID_VERIFYING_SSL = "verifyingSsl";
+  static final String ID_PEM_FILE_PATH = "pemFilePath";
+  static final String ID_PEM_STRING = "pemString";
+  static final String ID_OPEN_TIMEOUT = "openTimeout";
+  static final String ID_READ_TIMEOUT = "readTimeout";
+
+  static final String DEFAULT_KUBERNETES_JWT_PATH =
+      "/var/run/secrets/kubernetes.io/serviceaccount/token";
+  static final String DEFAULT_KUBERNETES_AUTH_MOUNT = "kubernetes";
+  static final String I18N_PREFIX =
+      
"i18n:org.apache.hop.core.variables.resolver.vault:VaultVariableResolver.";
+
+  private static final String GROUP_CONNECTION = "Connection";
+  private static final String GROUP_AUTHENTICATION = "Authentication";
+  private static final String GROUP_SECRETS = "Secrets";
+  private static final long REFRESH_MARGIN_MILLIS = 30_000L;
+
+  private final Object clientLock = new Object();
+
+  private transient Vault vaultClient;
+  private transient String clientSignature;
+  private transient long tokenExpiryMillis;
+  private transient boolean tokenRenewable;
 
   @GuiWidgetElement(
-      id = "vaultAddress",
-      order = "10",
-      label =
-          
"i18n:org.apache.hop.core.variables.resolver.vault:VaultVariableResolver.label.vaultAddress",
+      id = ID_VAULT_ADDRESS,
+      order = "010",
+      label = I18N_PREFIX + "label.vaultAddress",
       type = GuiElementType.TEXT,
-      parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID)
+      parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID,
+      groupType = GuiWidgetGroupType.BOXES,
+      group = GROUP_CONNECTION,
+      groupOrder = "010")
   @HopMetadataProperty
   protected String vaultAddress;
 
   @GuiWidgetElement(
-      id = "vaultToken",
-      order = "20",
-      label =
-          
"i18n:org.apache.hop.core.variables.resolver.vault:VaultVariableResolver.label.vaultToken",
+      id = ID_NAMESPACE,
+      order = "020",
+      label = I18N_PREFIX + "label.namespace",
+      type = GuiElementType.TEXT,
+      parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID,
+      groupType = GuiWidgetGroupType.BOXES,
+      group = GROUP_CONNECTION,
+      groupOrder = "010")
+  @HopMetadataProperty
+  protected String namespace;
+
+  @GuiWidgetElement(
+      id = ID_VERIFYING_SSL,
+      order = "030",
+      label = I18N_PREFIX + "label.verifyingSsl",
+      toolTip = I18N_PREFIX + "tooltip.verifyingSsl",
+      type = GuiElementType.CHECKBOX,
+      parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID,
+      groupType = GuiWidgetGroupType.BOXES,
+      group = GROUP_CONNECTION,
+      groupOrder = "010")
+  @HopMetadataProperty
+  protected boolean verifyingSsl;
+
+  @GuiWidgetElement(
+      id = ID_PEM_FILE_PATH,
+      order = "040",
+      label = I18N_PREFIX + "label.pemFilePath",
+      type = GuiElementType.FILENAME,
+      parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID,
+      groupType = GuiWidgetGroupType.BOXES,
+      group = GROUP_CONNECTION,
+      groupOrder = "010")
+  @HopMetadataProperty
+  protected String pemFilePath;
+
+  @GuiWidgetElement(
+      id = ID_PEM_STRING,
+      order = "050",
+      label = I18N_PREFIX + "label.pemString",
       type = GuiElementType.TEXT,
       password = true,
-      parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID)
+      parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID,
+      groupType = GuiWidgetGroupType.BOXES,
+      group = GROUP_CONNECTION,
+      groupOrder = "010")
   @HopMetadataProperty
-  protected String vaultToken;
+  protected String pemString;
 
   @GuiWidgetElement(
-      id = "pathPrefix",
-      order = "30",
-      label =
-          
"i18n:org.apache.hop.core.variables.resolver.vault:VaultVariableResolver.label.pathPrefix",
+      id = ID_OPEN_TIMEOUT,
+      order = "060",
+      label = I18N_PREFIX + "label.openTimeout",
       type = GuiElementType.TEXT,
-      parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID)
+      parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID,
+      groupType = GuiWidgetGroupType.BOXES,
+      group = GROUP_CONNECTION,
+      groupOrder = "010")
   @HopMetadataProperty
-  protected String pathPrefix;
+  protected String openTimeout;
 
   @GuiWidgetElement(
-      id = "namespace",
-      order = "35",
-      label =
-          
"i18n:org.apache.hop.core.variables.resolver.vault:VaultVariableResolver.label.namespace",
+      id = ID_READ_TIMEOUT,
+      order = "070",
+      label = I18N_PREFIX + "label.readTimeout",
       type = GuiElementType.TEXT,
-      parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID)
+      parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID,
+      groupType = GuiWidgetGroupType.BOXES,
+      group = GROUP_CONNECTION,
+      groupOrder = "010")
   @HopMetadataProperty
-  protected String namespace;
+  protected String readTimeout;
 
   @GuiWidgetElement(
-      id = "verifyingSsl",
-      order = "40",
-      label =
-          
"i18n:org.apache.hop.core.variables.resolver.vault:VaultVariableResolver.label.verifyingSsl",
-      toolTip =
-          
"i18n:org.apache.hop.core.variables.resolver.vault:VaultVariableResolver.tooltip.verifyingSsl",
-      type = GuiElementType.CHECKBOX,
-      parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID)
+      id = ID_AUTHENTICATION_TYPE,
+      order = "010",
+      label = I18N_PREFIX + "label.authenticationType",
+      toolTip = I18N_PREFIX + "tooltip.authenticationType",
+      type = GuiElementType.COMBO,
+      comboValuesMethod = "getAuthenticationTypes",
+      parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID,
+      groupType = GuiWidgetGroupType.BOXES,
+      group = GROUP_AUTHENTICATION,
+      groupOrder = "020")
   @HopMetadataProperty
-  protected boolean verifyingSsl;
+  protected String authenticationType;
+
+  @GuiWidgetElement(
+      id = ID_VAULT_TOKEN,
+      order = "020",
+      label = I18N_PREFIX + "label.vaultToken",
+      type = GuiElementType.TEXT,
+      password = true,
+      parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID,
+      groupType = GuiWidgetGroupType.BOXES,
+      group = GROUP_AUTHENTICATION,
+      groupOrder = "020")
+  @HopMetadataProperty
+  protected String vaultToken;
 
   @GuiWidgetElement(
-      id = "pemFilePath",
-      order = "50",
-      label =
-          
"i18n:org.apache.hop.core.variables.resolver.vault:VaultVariableResolver.label.pemFilePath",
+      id = ID_KUBERNETES_ROLE,
+      order = "030",
+      label = I18N_PREFIX + "label.kubernetesRole",
+      toolTip = I18N_PREFIX + "tooltip.kubernetesRole",
+      type = GuiElementType.TEXT,
+      parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID,
+      groupType = GuiWidgetGroupType.BOXES,
+      group = GROUP_AUTHENTICATION,
+      groupOrder = "020")
+  @HopMetadataProperty
+  protected String kubernetesRole;
+
+  @GuiWidgetElement(
+      id = ID_KUBERNETES_JWT_PATH,
+      order = "040",
+      label = I18N_PREFIX + "label.kubernetesJwtPath",
+      toolTip = I18N_PREFIX + "tooltip.kubernetesJwtPath",
       type = GuiElementType.FILENAME,
-      parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID)
+      parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID,
+      groupType = GuiWidgetGroupType.BOXES,
+      group = GROUP_AUTHENTICATION,
+      groupOrder = "020")
   @HopMetadataProperty
-  protected String pemFilePath;
+  protected String kubernetesJwtPath;
 
   @GuiWidgetElement(
-      id = "pemString",
-      order = "60",
-      label =
-          
"i18n:org.apache.hop.core.variables.resolver.vault:VaultVariableResolver.label.pemString",
+      id = ID_KUBERNETES_JWT,
+      order = "050",
+      label = I18N_PREFIX + "label.kubernetesJwt",
+      toolTip = I18N_PREFIX + "tooltip.kubernetesJwt",
       type = GuiElementType.TEXT,
       password = true,
-      parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID)
+      parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID,
+      groupType = GuiWidgetGroupType.BOXES,
+      group = GROUP_AUTHENTICATION,
+      groupOrder = "020")
   @HopMetadataProperty
-  protected String pemString;
+  protected String kubernetesJwt;
 
   @GuiWidgetElement(
-      id = "openTimeout",
-      order = "70",
-      label =
-          
"i18n:org.apache.hop.core.variables.resolver.vault:VaultVariableResolver.label.openTimeout",
+      id = ID_KUBERNETES_AUTH_PATH,
+      order = "060",
+      label = I18N_PREFIX + "label.kubernetesAuthPath",
+      toolTip = I18N_PREFIX + "tooltip.kubernetesAuthPath",
       type = GuiElementType.TEXT,
-      parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID)
+      parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID,
+      groupType = GuiWidgetGroupType.BOXES,
+      group = GROUP_AUTHENTICATION,
+      groupOrder = "020")
   @HopMetadataProperty
-  protected String openTimeout;
+  protected String kubernetesAuthPath;
 
   @GuiWidgetElement(
-      id = "readTimeout",
-      order = "80",
-      label =
-          
"i18n:org.apache.hop.core.variables.resolver.vault:VaultVariableResolver.label.readTimeout",
+      id = ID_PATH_PREFIX,
+      order = "010",
+      label = I18N_PREFIX + "label.pathPrefix",
       type = GuiElementType.TEXT,
-      parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID)
+      parentId = VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID,
+      groupType = GuiWidgetGroupType.BOXES,
+      group = GROUP_SECRETS,
+      groupOrder = "030")
   @HopMetadataProperty
-  protected String readTimeout;
+  protected String pathPrefix;
+
+  protected BaseVaultVariableResolver() {
+    authenticationType = VaultAuthType.TOKEN.name();
+  }
 
   @Override
   public String resolve(String secretPath, IVariables variables) throws 
HopException {
+    if (StringUtils.isEmpty(secretPath)) {
+      return null;
+    }
     try {
-      // If we don't have any argument, give up immediately.
-      //
-      if (StringUtils.isEmpty(secretPath)) {
-        return null;
+      return lookupSecret(secretPath, variables);
+    } catch (Exception first) {
+      if (shouldRetryAuth(first, variables)) {
+        invalidateCachedClient();
+        try {
+          return lookupSecret(secretPath, variables);
+        } catch (Exception retry) {
+          LogChannel.GENERAL.logError(
+              "Error looking up secret '" + secretPath + "' in the Variable 
resolver", retry);
+          return null;
+        }
+      }
+      LogChannel.GENERAL.logError(
+          "Error looking up secret '" + secretPath + "' in the Variable 
resolver", first);
+      return null;
+    }
+  }
+
+  private String lookupSecret(String secretPath, IVariables variables) throws 
Exception {
+    Vault vault = getVault(variables);
+
+    String path;
+    if (StringUtils.isNotEmpty(pathPrefix)) {
+      path = variables.resolve(pathPrefix) + secretPath;
+    } else {
+      path = secretPath;
+    }
+
+    LogicalResponse logicalResponse = vault.logical().read(path);
+    if (logicalResponse == null) {
+      LogChannel.GENERAL.logDetailed(
+          "The secret with path '" + secretPath + "' was not found in the 
vault");
+      return null;
+    }
+    // If we don't have a value to retrieve, simply return the "data" value.
+    //
+    return logicalResponse.getData().get("data");
+  }
+
+  /**
+   * Returns a Vault client for the current configuration, logging in or 
renewing when needed. The
+   * client is reused until the resolved configuration changes or a Kubernetes 
token is close to
+   * expiry.
+   */
+  protected Vault getVault(IVariables variables) throws HopException {
+    String signature = clientSignature(variables);
+
+    synchronized (clientLock) {
+      if (vaultClient != null && signature.equals(clientSignature) && 
!tokenNeedsRefresh()) {
+        return vaultClient;
       }
+      if (vaultClient != null
+          && signature.equals(clientSignature)
+          && tokenNeedsRefresh()
+          && tokenRenewable
+          && tryRenew()) {
+        return vaultClient;
+      }
+
+      vaultClient = buildVault(variables);
+      clientSignature = signature;
+      return vaultClient;
+    }
+  }
+
+  protected Vault buildVault(IVariables variables) throws HopException {
+    VaultAuthType authType = 
parseAuthType(variables.resolve(authenticationType));
+    VaultConfig vaultConfig = buildBaseConfig(variables);
+
+    if (authType == VaultAuthType.KUBERNETES) {
+      return buildKubernetesVault(variables, vaultConfig);
+    }
+    return buildTokenVault(variables, vaultConfig);
+  }
+
+  private Vault buildTokenVault(IVariables variables, VaultConfig vaultConfig) 
throws HopException {
+    String actualVaultToken = variables.resolve(vaultToken);
+    if (StringUtils.isEmpty(actualVaultToken)) {
+      throw new HopException(
+          "A Vault token is required when the authentication type is "
+              + VaultAuthType.TOKEN.name());
+    }
+    vaultConfig.token(actualVaultToken);
+    finalizeConfig(vaultConfig);
+    tokenExpiryMillis = 0L;
+    tokenRenewable = false;
+    return createVault(vaultConfig);
+  }
+
+  private Vault buildKubernetesVault(IVariables variables, VaultConfig 
vaultConfig)
+      throws HopException {
+    String role = variables.resolve(kubernetesRole);
+    if (StringUtils.isEmpty(role)) {
+      throw new HopException(
+          "A Kubernetes role is required when the authentication type is "
+              + VaultAuthType.KUBERNETES.name());
+    }
+    String jwt = loadServiceAccountJwt(variables);
+    String loginPath = 
kubernetesLoginPath(variables.resolve(kubernetesAuthPath));
+
+    finalizeConfig(vaultConfig);
+    Vault loginVault = createVault(vaultConfig);
+    AuthResponse response = loginByKubernetes(loginVault, role, jwt, 
loginPath);
+    String clientToken = response == null ? null : 
response.getAuthClientToken();
+    if (StringUtils.isEmpty(clientToken)) {
+      throw new HopException(
+          "Vault Kubernetes authentication did not return a client token for 
role '" + role + "'");
+    }
+
+    vaultConfig.token(clientToken);
+    finalizeConfig(vaultConfig);
+    updateLease(response);
+    return createVault(vaultConfig);
+  }
+
+  private static void finalizeConfig(VaultConfig vaultConfig) throws 
HopException {
+    try {
+      vaultConfig.build();
+    } catch (Exception e) {
+      throw new HopException("Error building the Vault client configuration", 
e);
+    }
+  }
+
+  protected Vault createVault(VaultConfig vaultConfig) {
+    return Vault.create(vaultConfig);
+  }
+
+  protected AuthResponse loginByKubernetes(Vault vault, String role, String 
jwt, String loginPath)
+      throws HopException {
+    try {
+      if (DEFAULT_KUBERNETES_LOGIN_PATH.equals(loginPath)) {
+        return vault.auth().loginByKubernetes(role, jwt);
+      }
+      return vault.auth().loginByKubernetes(role, jwt, loginPath);
+    } catch (Exception e) {
+      throw new HopException("Unable to log in to Vault with Kubernetes 
authentication", e);
+    }
+  }
+
+  private static final String DEFAULT_KUBERNETES_LOGIN_PATH =
+      "auth/" + DEFAULT_KUBERNETES_AUTH_MOUNT;
 
-      String actualVaultToken = variables.resolve(vaultToken);
+  protected String loadServiceAccountJwt(IVariables variables) throws 
HopException {
+    String jwt = variables.resolve(kubernetesJwt);
+    if (StringUtils.isNotEmpty(jwt)) {
+      return jwt.trim();
+    }
+    String path = variables.resolve(kubernetesJwtPath);
+    if (StringUtils.isEmpty(path)) {
+      path = DEFAULT_KUBERNETES_JWT_PATH;
+    }
+    try (InputStream is = HopVfs.getInputStream(path, variables)) {
+      return readUtf8StringFromInputStream(is).trim();
+    } catch (Exception e) {
+      throw new HopException(
+          "Could not read the Kubernetes service account token from '" + path 
+ "'", e);
+    }
+  }
+
+  static String kubernetesLoginPath(String mount) {
+    if (StringUtils.isEmpty(mount)) {
+      return DEFAULT_KUBERNETES_LOGIN_PATH;
+    }
+    String trimmed = mount.trim();
+    if (trimmed.startsWith("auth/")) {
+      return trimmed;
+    }
+    return "auth/" + trimmed;
+  }
+
+  VaultAuthType parseAuthType(String actualAuthType) throws HopException {
+    if (StringUtils.isEmpty(actualAuthType)) {
+      return VaultAuthType.TOKEN;
+    }
+    try {
+      return 
VaultAuthType.valueOf(actualAuthType.trim().toUpperCase(Locale.ROOT));
+    } catch (IllegalArgumentException e) {
+      throw new HopException(
+          "Unknown Vault authentication type '"
+              + actualAuthType
+              + "'. Valid values are: "
+              + String.join(", ", authTypeNames()),
+          e);
+    }
+  }
+
+  public List<String> getAuthenticationTypes(
+      ILogChannel logChannel, IHopMetadataProvider metadataProvider) {
+    return authTypeNames();
+  }
+
+  private static List<String> authTypeNames() {
+    List<String> names = new ArrayList<>();
+    for (VaultAuthType type : VaultAuthType.values()) {
+      names.add(type.name());
+    }
+    return names;
+  }
+
+  private VaultConfig buildBaseConfig(IVariables variables) throws 
HopException {
+    try {
       String actualVaultAddress = variables.resolve(vaultAddress);
       final VaultConfig vaultConfig = new VaultConfig();
       vaultConfig.address(actualVaultAddress);
-      vaultConfig.token(actualVaultToken);
       vaultConfig.engineVersion(1);
 
       if (StringUtils.isNotEmpty(namespace)) {
@@ -162,10 +497,8 @@ public abstract class BaseVaultVariableResolver implements 
IVariableResolver {
       final SslConfig sslConfig = new SslConfig();
       sslConfig.verify(isVerifyingSsl());
       String pemUtf8 = null;
-      // Is our PEM String located in a file?
-      //
       if (StringUtils.isNotEmpty(pemFilePath)) {
-        try (InputStream is = 
HopVfs.getInputStream(variables.resolve(pemFilePath))) {
+        try (InputStream is = 
HopVfs.getInputStream(variables.resolve(pemFilePath), variables)) {
           pemUtf8 = readUtf8StringFromInputStream(is);
         }
       } else if (StringUtils.isNotEmpty(pemString)) {
@@ -189,35 +522,127 @@ public abstract class BaseVaultVariableResolver 
implements IVariableResolver {
           vaultConfig.readTimeout(timeOut);
         }
       }
+      return vaultConfig;
+    } catch (HopException e) {
+      throw e;
+    } catch (Exception e) {
+      throw new HopException("Error building the Vault client configuration", 
e);
+    }
+  }
 
-      vaultConfig.build();
+  private String clientSignature(IVariables variables) {
+    return String.join(
+        "\t",
+        Const.NVL(variables.resolve(vaultAddress), ""),
+        Const.NVL(variables.resolve(authenticationType), ""),
+        Const.NVL(variables.resolve(vaultToken), ""),
+        Const.NVL(variables.resolve(kubernetesRole), ""),
+        Const.NVL(variables.resolve(kubernetesJwtPath), ""),
+        Const.NVL(variables.resolve(kubernetesJwt), ""),
+        Const.NVL(variables.resolve(kubernetesAuthPath), ""),
+        Const.NVL(variables.resolve(namespace), ""),
+        Boolean.toString(verifyingSsl),
+        Const.NVL(variables.resolve(pemFilePath), ""),
+        Const.NVL(variables.resolve(pemString), ""),
+        Const.NVL(variables.resolve(openTimeout), ""),
+        Const.NVL(variables.resolve(readTimeout), ""));
+  }
 
-      final Vault vault = Vault.create(vaultConfig);
+  private boolean tokenNeedsRefresh() {
+    if (tokenExpiryMillis <= 0L) {
+      return false;
+    }
+    return System.currentTimeMillis() >= tokenExpiryMillis - 
REFRESH_MARGIN_MILLIS;
+  }
 
-      String path;
-      if (StringUtils.isNotEmpty(pathPrefix)) {
-        path = variables.resolve(pathPrefix) + secretPath;
-      } else {
-        path = secretPath;
-      }
+  private boolean tryRenew() {
+    try {
+      AuthResponse renewed = vaultClient.auth().renewSelf();
+      updateLease(renewed);
+      return true;
+    } catch (Exception e) {
+      LogChannel.GENERAL.logDetailed(
+          "Could not renew the Vault token, will re-authenticate instead", e);
+      return false;
+    }
+  }
+
+  private void updateLease(AuthResponse response) {
+    if (response == null) {
+      tokenExpiryMillis = 0L;
+      tokenRenewable = false;
+      return;
+    }
+    long leaseSeconds = response.getAuthLeaseDuration();
+    tokenRenewable = response.isAuthRenewable();
+    // A missing lease would otherwise cache the token forever. Use the 
refresh margin so a
+    // Kubernetes login without TTL is retried instead of being kept until it 
403s.
+    tokenExpiryMillis =
+        System.currentTimeMillis()
+            + (leaseSeconds <= 0L ? REFRESH_MARGIN_MILLIS : leaseSeconds * 
1000L);
+  }
+
+  void expireCachedToken() {
+    tokenExpiryMillis = System.currentTimeMillis();
+    tokenRenewable = false;
+  }
+
+  void setCachedLeaseForTest(long expiryMillis, boolean renewable) {
+    tokenExpiryMillis = expiryMillis;
+    tokenRenewable = renewable;
+  }
+
+  void invalidateCachedClient() {
+    synchronized (clientLock) {
+      vaultClient = null;
+      clientSignature = null;
+      tokenExpiryMillis = 0L;
+      tokenRenewable = false;
+    }
+  }
+
+  boolean shouldRetryAuth(Exception error, IVariables variables) {
+    if (!isAuthFailure(error)) {
+      return false;
+    }
+    try {
+      return parseAuthType(variables.resolve(authenticationType)) == 
VaultAuthType.KUBERNETES;
+    } catch (HopException e) {
+      return false;
+    }
+  }
 
-      LogicalResponse logicalResponse = vault.logical().read(path);
-      if (logicalResponse == null) {
-        LogChannel.GENERAL.logDetailed(
-            "The secret with path '" + secretPath + "' was not found in the 
vault");
-        return null;
+  static boolean isAuthFailure(Throwable error) {
+    for (Throwable current = error; current != null; current = 
current.getCause()) {
+      if (current instanceof VaultException vaultException) {
+        int status = vaultException.getHttpStatusCode();
+        if (status == 401 || status == 403) {
+          return true;
+        }
       }
-      // If we don't have a value to retrieve, simple return the "data" value.
-      //
-      return logicalResponse.getData().get("data");
-    } catch (Exception e) {
-      LogChannel.GENERAL.logError(
-          "Error looking up secret '" + secretPath + "' in the Variable 
resolver", e);
-      return null;
+    }
+    return false;
+  }
+
+  void clearUnusedCredentials() {
+    if (authenticationType != null && authenticationType.contains("${")) {
+      return;
+    }
+    VaultAuthType authType;
+    try {
+      authType = parseAuthType(authenticationType);
+    } catch (HopException e) {
+      return;
+    }
+    if (authType != VaultAuthType.TOKEN) {
+      vaultToken = "";
+    }
+    if (authType != VaultAuthType.KUBERNETES) {
+      kubernetesJwt = "";
     }
   }
 
-  // Read the PEM file content in UTF8 from an input stream.
+  // Read the PEM or JWT file content in UTF8 from an input stream.
   //
   private String readUtf8StringFromInputStream(final InputStream input) throws 
IOException {
     final StringBuilder utf8 = new StringBuilder();
@@ -234,7 +659,8 @@ public abstract class BaseVaultVariableResolver implements 
IVariableResolver {
 
   @Override
   public void init() {
-    // Not used today
+    // The client is built lazily on the first resolve() call: only then do we 
have the variables
+    // needed to resolve the configuration fields.
   }
 
   @Override
@@ -242,4 +668,96 @@ public abstract class BaseVaultVariableResolver implements 
IVariableResolver {
 
   @Override
   public abstract String getPluginName();
+
+  @Override
+  public void widgetsCreated(GuiCompositeWidgets compositeWidgets) {
+    hideFieldsThatDoNotApply(compositeWidgets);
+  }
+
+  @Override
+  public void widgetsPopulated(GuiCompositeWidgets compositeWidgets) {
+    hideFieldsThatDoNotApply(compositeWidgets);
+  }
+
+  @Override
+  public void widgetModified(
+      GuiCompositeWidgets compositeWidgets, Control changedWidget, String 
widgetId) {
+    if (ID_AUTHENTICATION_TYPE.equals(widgetId)) {
+      hideFieldsThatDoNotApply(compositeWidgets);
+    }
+  }
+
+  @Override
+  public void persistContents(GuiCompositeWidgets compositeWidgets) {
+    clearUnusedCredentials();
+  }
+
+  private void hideFieldsThatDoNotApply(GuiCompositeWidgets compositeWidgets) {
+    VaultAuthType authType = readAuthType(compositeWidgets);
+    if (authType == null) {
+      // A variable or unknown type: keep every credential visible so we do 
not wipe values
+      // that might still be needed once the expression is resolved.
+      compositeWidgets.setWidgetsHidden(this, Set.of());
+      return;
+    }
+    Set<String> hidden = new HashSet<>();
+    if (authType != VaultAuthType.TOKEN) {
+      hidden.add(ID_VAULT_TOKEN);
+      clearTextWidget(compositeWidgets, ID_VAULT_TOKEN);
+    }
+    if (authType != VaultAuthType.KUBERNETES) {
+      hidden.add(ID_KUBERNETES_ROLE);
+      hidden.add(ID_KUBERNETES_JWT_PATH);
+      hidden.add(ID_KUBERNETES_JWT);
+      hidden.add(ID_KUBERNETES_AUTH_PATH);
+      clearTextWidget(compositeWidgets, ID_KUBERNETES_JWT);
+    }
+    compositeWidgets.setWidgetsHidden(this, hidden);
+  }
+
+  private static void clearTextWidget(GuiCompositeWidgets compositeWidgets, 
String id) {
+    Control control = compositeWidgets.getWidgetsMap().get(id);
+    if (control instanceof TextVar textVar) {
+      if (StringUtils.isNotEmpty(textVar.getText())) {
+        textVar.setText("");
+      }
+    } else if (control instanceof Text text) {
+      if (StringUtils.isNotEmpty(text.getText())) {
+        text.setText("");
+      }
+    }
+  }
+
+  /**
+   * @return the selected auth type, or {@code null} when the value is a 
variable / unknown so the
+   *     editor must not hide or clear credentials
+   */
+  private VaultAuthType readAuthType(GuiCompositeWidgets compositeWidgets) {
+    Control control = 
compositeWidgets.getWidgetsMap().get(ID_AUTHENTICATION_TYPE);
+    String text = comboText(control);
+    if (StringUtils.isEmpty(text)) {
+      text = authenticationType;
+    }
+    if (StringUtils.isEmpty(text)) {
+      return VaultAuthType.TOKEN;
+    }
+    if (text.contains("${")) {
+      return null;
+    }
+    try {
+      return VaultAuthType.valueOf(text.trim().toUpperCase(Locale.ROOT));
+    } catch (IllegalArgumentException e) {
+      return VaultAuthType.TOKEN;
+    }
+  }
+
+  private static String comboText(Control control) {
+    if (control instanceof Combo combo) {
+      return combo.getText();
+    }
+    if (control instanceof ComboVar comboVar) {
+      return comboVar.getText();
+    }
+    return null;
+  }
 }
diff --git 
a/plugins/tech/vault/src/main/java/org/apache/hop/core/variables/resolver/vault/VaultAuthType.java
 
b/plugins/tech/vault/src/main/java/org/apache/hop/core/variables/resolver/vault/VaultAuthType.java
new file mode 100644
index 0000000000..f3b520e3a7
--- /dev/null
+++ 
b/plugins/tech/vault/src/main/java/org/apache/hop/core/variables/resolver/vault/VaultAuthType.java
@@ -0,0 +1,30 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.hop.core.variables.resolver.vault;
+
+/** Authentication method used by the HashiCorp Vault and OpenBAO variable 
resolvers. */
+public enum VaultAuthType {
+  /** A Vault/OpenBAO client token, typically a long-lived token stored in the 
resolver metadata. */
+  TOKEN,
+  /**
+   * Kubernetes auth: Hop reads a ServiceAccount JWT and exchanges it with 
Vault for a short-lived
+   * client token.
+   */
+  KUBERNETES
+}
diff --git 
a/plugins/tech/vault/src/main/resources/org/apache/hop/core/variables/resolver/vault/messages/messages_en_US.properties
 
b/plugins/tech/vault/src/main/resources/org/apache/hop/core/variables/resolver/vault/messages/messages_en_US.properties
index ca6271ddc7..3b255675e2 100644
--- 
a/plugins/tech/vault/src/main/resources/org/apache/hop/core/variables/resolver/vault/messages/messages_en_US.properties
+++ 
b/plugins/tech/vault/src/main/resources/org/apache/hop/core/variables/resolver/vault/messages/messages_en_US.properties
@@ -25,4 +25,14 @@ VaultVariableResolver.tooltip.verifyingSsl = Enable this in 
production!  It vali
 VaultVariableResolver.label.pemFilePath = PEM (X.509 certificate) file path
 VaultVariableResolver.label.pemString = PEM (X.509 certificate) String
 VaultVariableResolver.label.openTimeout = Open connection timeout
-VaultVariableResolver.label.readTimeout = Read timeout
\ No newline at end of file
+VaultVariableResolver.label.readTimeout = Read timeout
+VaultVariableResolver.label.authenticationType = Authentication type
+VaultVariableResolver.tooltip.authenticationType = TOKEN uses a Vault client 
token. KUBERNETES exchanges a Kubernetes ServiceAccount JWT for a short-lived 
Vault token.
+VaultVariableResolver.label.kubernetesRole = Kubernetes role
+VaultVariableResolver.tooltip.kubernetesRole = The Vault Kubernetes auth role 
bound to this workload's ServiceAccount.
+VaultVariableResolver.label.kubernetesJwtPath = Kubernetes JWT file path
+VaultVariableResolver.tooltip.kubernetesJwtPath = Path of the ServiceAccount 
token file. Defaults to /var/run/secrets/kubernetes.io/serviceaccount/token. 
You can also use a variable such as '${KUBERNETES_JWT_PATH}'.
+VaultVariableResolver.label.kubernetesJwt = Kubernetes JWT
+VaultVariableResolver.tooltip.kubernetesJwt = Optional JWT string, used 
instead of the file when set. Typical in tests or when the token is injected as 
'${KUBERNETES_JWT}'.
+VaultVariableResolver.label.kubernetesAuthPath = Kubernetes auth mount path
+VaultVariableResolver.tooltip.kubernetesAuthPath = Vault auth mount, without 
the leading auth/. Defaults to kubernetes.
\ No newline at end of file
diff --git 
a/plugins/tech/vault/src/test/java/org/apache/hop/core/variables/resolver/vault/KubernetesServiceAccountJwt.java
 
b/plugins/tech/vault/src/test/java/org/apache/hop/core/variables/resolver/vault/KubernetesServiceAccountJwt.java
new file mode 100644
index 0000000000..790586ddd6
--- /dev/null
+++ 
b/plugins/tech/vault/src/test/java/org/apache/hop/core/variables/resolver/vault/KubernetesServiceAccountJwt.java
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.core.variables.resolver.vault;
+
+import java.nio.charset.StandardCharsets;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.Signature;
+import java.util.Base64;
+
+/**
+ * Compact RS256 JWT with Kubernetes ServiceAccount claims, for tests only. 
Vault's Kubernetes auth
+ * backend only accepts RSA/ECDSA JWT algorithms, even when it does not verify 
the signature.
+ */
+final class KubernetesServiceAccountJwt {
+
+  private KubernetesServiceAccountJwt() {}
+
+  static String create(String namespace, String name) {
+    String header = b64("{\"alg\":\"RS256\",\"typ\":\"JWT\"}");
+    String payload =
+        b64(
+            "{"
+                + "\"iss\":\"kubernetes/serviceaccount\","
+                + "\"kubernetes.io/serviceaccount/namespace\":\""
+                + namespace
+                + "\","
+                + "\"kubernetes.io/serviceaccount/secret.name\":\""
+                + name
+                + "-token\","
+                + "\"kubernetes.io/serviceaccount/service-account.name\":\""
+                + name
+                + "\","
+                + "\"kubernetes.io/serviceaccount/service-account.uid\":\"1\","
+                + "\"sub\":\"system:serviceaccount:"
+                + namespace
+                + ":"
+                + name
+                + "\""
+                + "}");
+    String signingInput = header + "." + payload;
+    return signingInput + "." + b64(rsaSign(signingInput));
+  }
+
+  private static byte[] rsaSign(String signingInput) {
+    try {
+      KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
+      generator.initialize(2048);
+      KeyPair keyPair = generator.generateKeyPair();
+      Signature signature = Signature.getInstance("SHA256withRSA");
+      signature.initSign(keyPair.getPrivate());
+      signature.update(signingInput.getBytes(StandardCharsets.UTF_8));
+      return signature.sign();
+    } catch (Exception e) {
+      throw new IllegalStateException("Could not sign the test JWT", e);
+    }
+  }
+
+  private static String b64(String value) {
+    return b64(value.getBytes(StandardCharsets.UTF_8));
+  }
+
+  private static String b64(byte[] value) {
+    return Base64.getUrlEncoder().withoutPadding().encodeToString(value);
+  }
+}
diff --git 
a/plugins/tech/vault/src/test/java/org/apache/hop/core/variables/resolver/vault/KubernetesTokenReviewMock.java
 
b/plugins/tech/vault/src/test/java/org/apache/hop/core/variables/resolver/vault/KubernetesTokenReviewMock.java
new file mode 100644
index 0000000000..3c9975628a
--- /dev/null
+++ 
b/plugins/tech/vault/src/test/java/org/apache/hop/core/variables/resolver/vault/KubernetesTokenReviewMock.java
@@ -0,0 +1,147 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.core.variables.resolver.vault;
+
+import com.sun.net.httpserver.HttpServer;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.concurrent.Executors;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Minimal Kubernetes TokenReview API used by Vault's Kubernetes auth backend 
in tests.
+ * Authenticates JWTs whose {@code sub} claim is {@code 
system:serviceaccount:<namespace>:<name>}
+ * for the bound service account.
+ */
+final class KubernetesTokenReviewMock implements AutoCloseable {
+
+  private static final Pattern SUB = 
Pattern.compile("\"sub\"\\s*:\\s*\"([^\"]+)\"");
+
+  private final HttpServer server;
+
+  KubernetesTokenReviewMock() throws IOException {
+    server = HttpServer.create(new InetSocketAddress("0.0.0.0", 0), 0);
+    server.createContext("/", this::handle);
+    server.setExecutor(Executors.newCachedThreadPool());
+    server.start();
+  }
+
+  int getPort() {
+    return server.getAddress().getPort();
+  }
+
+  @Override
+  public void close() {
+    server.stop(0);
+  }
+
+  private void handle(com.sun.net.httpserver.HttpExchange exchange) throws 
IOException {
+    String path = exchange.getRequestURI().getPath();
+    String method = exchange.getRequestMethod();
+    byte[] body = readBody(exchange);
+
+    if ("GET".equalsIgnoreCase(method) && path.contains("healthz")) {
+      send(exchange, 200, "text/plain", "ok");
+      return;
+    }
+
+    if ("POST".equalsIgnoreCase(method) && path.contains("tokenreviews")) {
+      send(
+          exchange, 200, "application/json", tokenReview(new String(body, 
StandardCharsets.UTF_8)));
+      return;
+    }
+
+    send(exchange, 200, "application/json", 
"{\"kind\":\"APIVersions\",\"versions\":[\"v1\"]}");
+  }
+
+  private static byte[] readBody(com.sun.net.httpserver.HttpExchange exchange) 
throws IOException {
+    try (InputStream in = exchange.getRequestBody()) {
+      return in.readAllBytes();
+    }
+  }
+
+  private static void send(
+      com.sun.net.httpserver.HttpExchange exchange, int status, String 
contentType, String body)
+      throws IOException {
+    byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
+    exchange.getResponseHeaders().set("Content-Type", contentType);
+    exchange.sendResponseHeaders(status, bytes.length);
+    try (OutputStream out = exchange.getResponseBody()) {
+      out.write(bytes);
+    }
+  }
+
+  private static String tokenReview(String requestBody) {
+    String jwt = jsonString(requestBody, "token");
+    String sub = jwtSubject(jwt);
+    boolean authenticated = sub.startsWith("system:serviceaccount:");
+    String username = authenticated ? sub : "";
+    return """
+        {
+          "apiVersion": "authentication.k8s.io/v1",
+          "kind": "TokenReview",
+          "status": {
+            "authenticated": %s,
+            "user": {
+              "username": "%s",
+              "uid": "1",
+              "groups": ["system:serviceaccounts"]
+            }
+          }
+        }
+        """
+        .formatted(authenticated, username);
+  }
+
+  private static String jsonString(String json, String field) {
+    Matcher matcher =
+        Pattern.compile("\"" + Pattern.quote(field) + 
"\"\\s*:\\s*\"([^\"]*)\"").matcher(json);
+    return matcher.find() ? matcher.group(1) : "";
+  }
+
+  static String jwtSubject(String jwt) {
+    if (jwt == null || jwt.isBlank()) {
+      return "";
+    }
+    String[] parts = jwt.split("\\.");
+    if (parts.length < 2) {
+      return "";
+    }
+    try {
+      String payload =
+          new String(Base64.getUrlDecoder().decode(padBase64(parts[1])), 
StandardCharsets.UTF_8);
+      Matcher matcher = SUB.matcher(payload);
+      return matcher.find() ? matcher.group(1) : "";
+    } catch (IllegalArgumentException e) {
+      return "";
+    }
+  }
+
+  private static String padBase64(String value) {
+    int remainder = value.length() % 4;
+    if (remainder == 0) {
+      return value;
+    }
+    return value + "=".repeat(4 - remainder);
+  }
+}
diff --git 
a/plugins/tech/vault/src/test/java/org/apache/hop/core/variables/resolver/vault/VaultKubernetesAuthIT.java
 
b/plugins/tech/vault/src/test/java/org/apache/hop/core/variables/resolver/vault/VaultKubernetesAuthIT.java
new file mode 100644
index 0000000000..55cdbaecb7
--- /dev/null
+++ 
b/plugins/tech/vault/src/test/java/org/apache/hop/core/variables/resolver/vault/VaultKubernetesAuthIT.java
@@ -0,0 +1,301 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.core.variables.resolver.vault;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import io.github.jopenlibs.vault.Vault;
+import io.github.jopenlibs.vault.response.AuthResponse;
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.variables.Variables;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.wait.strategy.Wait;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+import org.testcontainers.utility.DockerImageName;
+
+@Testcontainers(disabledWithoutDocker = true)
+class VaultKubernetesAuthIT {
+
+  private static final String ROOT_TOKEN = "myroot";
+  private static final String SECRET_PATH = "secret/data/hop";
+  private static final HttpClient HTTP = HttpClient.newHttpClient();
+  static final KubernetesTokenReviewMock TOKEN_REVIEW = startTokenReview();
+
+  @Container
+  static GenericContainer<?> vault =
+      new GenericContainer<>(DockerImageName.parse("hashicorp/vault:1.19.0"))
+          .withExposedPorts(8200)
+          .withEnv("VAULT_DEV_ROOT_TOKEN_ID", ROOT_TOKEN)
+          .withEnv("VAULT_DEV_LISTEN_ADDRESS", "0.0.0.0:8200")
+          .withEnv("SKIP_SETCAP", "true")
+          .withAccessToHost(true)
+          .waitingFor(
+              Wait.forHttp("/v1/sys/health")
+                  .forPort(8200)
+                  .forStatusCodeMatching(code -> code == 200 || code == 429)
+                  .withStartupTimeout(Duration.ofMinutes(2)));
+
+  @TempDir static Path tempDir;
+
+  @BeforeAll
+  static void configureVault() throws Exception {
+    HopLogStore.init();
+    String kubernetesHost = "http://host.testcontainers.internal:"; + 
TOKEN_REVIEW.getPort();
+
+    vaultWrite("/v1/sys/auth/kubernetes", "{\"type\":\"kubernetes\"}");
+    vaultWrite(
+        "/v1/auth/kubernetes/config",
+        """
+        {
+          "kubernetes_host": "%s",
+          "disable_iss_validation": true,
+          "disable_local_ca_jwt": true,
+          "token_reviewer_jwt": "dummy"
+        }
+        """
+            .formatted(kubernetesHost));
+    vaultWrite(
+        "/v1/sys/policies/acl/hop-read",
+        """
+        {
+          "policy": "path \\"secret/data/*\\" { capabilities = [\\"read\\"] }"
+        }
+        """);
+    vaultWrite(
+        "/v1/auth/kubernetes/role/hop",
+        """
+        {
+          "bound_service_account_names": "hop",
+          "bound_service_account_namespaces": "default",
+          "policies": ["hop-read"],
+          "ttl": "1h"
+        }
+        """);
+    vaultWrite("/v1/sys/auth/k8s", "{\"type\":\"kubernetes\"}");
+    vaultWrite(
+        "/v1/auth/k8s/config",
+        """
+        {
+          "kubernetes_host": "%s",
+          "disable_iss_validation": true,
+          "disable_local_ca_jwt": true,
+          "token_reviewer_jwt": "dummy"
+        }
+        """
+            .formatted(kubernetesHost));
+    vaultWrite(
+        "/v1/auth/k8s/role/hop",
+        """
+        {
+          "bound_service_account_names": "hop",
+          "bound_service_account_namespaces": "default",
+          "policies": ["hop-read"],
+          "ttl": "1h"
+        }
+        """);
+    vaultWrite(
+        "/v1/" + SECRET_PATH,
+        """
+        {
+          "data": {
+            "hostname": "localhost",
+            "password": "some-password"
+          }
+        }
+        """);
+
+    var probe = vault.execInContainer("wget", "-qO-", "-T", "5", 
kubernetesHost + "/healthz");
+    if (probe.getExitCode() != 0) {
+      throw new IllegalStateException(
+          "Vault cannot reach the TokenReview mock at "
+              + kubernetesHost
+              + ": exit="
+              + probe.getExitCode()
+              + " stdout="
+              + probe.getStdout()
+              + " stderr="
+              + probe.getStderr()
+              + " vaultLogs="
+              + vault.getLogs());
+    }
+  }
+
+  @AfterAll
+  static void stopMock() {
+    TOKEN_REVIEW.close();
+  }
+
+  private static KubernetesTokenReviewMock startTokenReview() {
+    try {
+      KubernetesTokenReviewMock mock = new KubernetesTokenReviewMock();
+      org.testcontainers.Testcontainers.exposeHostPorts(mock.getPort());
+      return mock;
+    } catch (IOException e) {
+      throw new ExceptionInInitializerError(e);
+    }
+  }
+
+  @Test
+  void tokenAuthStillReadsTheSecret() throws Exception {
+    VaultVariableResolver resolver = tokenResolver();
+    String value = resolver.resolve(SECRET_PATH, variables());
+    assertNotNull(value);
+    assertTrue(value.contains("some-password"), value);
+  }
+
+  @Test
+  void kubernetesAuthWithInlineJwtReadsTheSecret() throws Exception {
+    VaultVariableResolver resolver = kubernetesResolver();
+    resolver.setKubernetesJwt(KubernetesServiceAccountJwt.create("default", 
"hop"));
+    String value = resolveOrThrow(resolver);
+    assertTrue(value.contains("some-password"), value);
+  }
+
+  @Test
+  void kubernetesAuthWithJwtFileReadsTheSecret() throws Exception {
+    Path jwtFile = tempDir.resolve("token");
+    Files.writeString(jwtFile, KubernetesServiceAccountJwt.create("default", 
"hop"));
+
+    VaultVariableResolver resolver = kubernetesResolver();
+    resolver.setKubernetesJwt("");
+    resolver.setKubernetesJwtPath(jwtFile.toString());
+    String value = resolveOrThrow(resolver);
+    assertTrue(value.contains("some-password"), value);
+  }
+
+  @Test
+  void kubernetesAuthWithCustomMountPathReadsTheSecret() throws Exception {
+    VaultVariableResolver resolver = kubernetesResolver();
+    resolver.setKubernetesAuthPath("k8s");
+    resolver.setKubernetesJwt(KubernetesServiceAccountJwt.create("default", 
"hop"));
+    String value = resolveOrThrow(resolver);
+    assertTrue(value.contains("some-password"), value);
+  }
+
+  @Test
+  void kubernetesAuthWithWrongRoleReturnsNull() throws Exception {
+    VaultVariableResolver resolver = kubernetesResolver();
+    resolver.setKubernetesRole("missing-role");
+    resolver.setKubernetesJwt(KubernetesServiceAccountJwt.create("default", 
"hop"));
+    assertNull(resolver.resolve(SECRET_PATH, variables()));
+  }
+
+  @Test
+  void kubernetesAuthWithUnauthenticatedJwtReturnsNull() throws Exception {
+    VaultVariableResolver resolver = kubernetesResolver();
+    resolver.setKubernetesJwt("not-a-jwt");
+    assertNull(resolver.resolve(SECRET_PATH, variables()));
+  }
+
+  @Test
+  void kubernetesClientIsCachedAcrossResolves() throws Exception {
+    CountingLoginResolver resolver = new CountingLoginResolver();
+    resolver.setVaultAddress(vaultAddress());
+    resolver.setVerifyingSsl(false);
+    resolver.setAuthenticationType(VaultAuthType.KUBERNETES.name());
+    resolver.setKubernetesRole("hop");
+    resolver.setKubernetesJwt(KubernetesServiceAccountJwt.create("default", 
"hop"));
+
+    IVariables variables = variables();
+    assertNotNull(resolveOrThrow(resolver, variables));
+    assertNotNull(resolveOrThrow(resolver, variables));
+    assertTrue(resolver.logins >= 1);
+    assertTrue(
+        resolver.logins < 3, "expected the Vault client to be reused, logins=" 
+ resolver.logins);
+  }
+
+  private static VaultVariableResolver tokenResolver() {
+    VaultVariableResolver resolver = new VaultVariableResolver();
+    resolver.setVaultAddress(vaultAddress());
+    resolver.setVaultToken(ROOT_TOKEN);
+    resolver.setVerifyingSsl(false);
+    resolver.setAuthenticationType(VaultAuthType.TOKEN.name());
+    return resolver;
+  }
+
+  private static VaultVariableResolver kubernetesResolver() {
+    VaultVariableResolver resolver = new VaultVariableResolver();
+    resolver.setVaultAddress(vaultAddress());
+    resolver.setVerifyingSsl(false);
+    resolver.setAuthenticationType(VaultAuthType.KUBERNETES.name());
+    resolver.setKubernetesRole("hop");
+    return resolver;
+  }
+
+  private static IVariables variables() {
+    return new Variables();
+  }
+
+  private static String vaultAddress() {
+    return "http://"; + vault.getHost() + ":" + vault.getMappedPort(8200);
+  }
+
+  private static String resolveOrThrow(VaultVariableResolver resolver) throws 
Exception {
+    return resolveOrThrow(resolver, variables());
+  }
+
+  private static String resolveOrThrow(VaultVariableResolver resolver, 
IVariables variables)
+      throws Exception {
+    resolver.getVault(variables);
+    String value = resolver.resolve(SECRET_PATH, variables);
+    assertNotNull(value, "secret lookup returned null after a successful 
login");
+    return value;
+  }
+
+  private static void vaultWrite(String path, String json) throws Exception {
+    HttpRequest request =
+        HttpRequest.newBuilder(URI.create(vaultAddress() + path))
+            .timeout(Duration.ofSeconds(15))
+            .header("X-Vault-Token", ROOT_TOKEN)
+            .header("Content-Type", "application/json")
+            .POST(HttpRequest.BodyPublishers.ofString(json))
+            .build();
+    HttpResponse<String> response = HTTP.send(request, 
HttpResponse.BodyHandlers.ofString());
+    if (response.statusCode() >= 300) {
+      throw new IllegalStateException(
+          "Vault " + path + " returned " + response.statusCode() + ": " + 
response.body());
+    }
+  }
+
+  private static final class CountingLoginResolver extends 
VaultVariableResolver {
+    private int logins;
+
+    @Override
+    protected AuthResponse loginByKubernetes(Vault vault, String role, String 
jwt, String loginPath)
+        throws org.apache.hop.core.exception.HopException {
+      logins++;
+      return super.loginByKubernetes(vault, role, jwt, loginPath);
+    }
+  }
+}
diff --git 
a/plugins/tech/vault/src/test/java/org/apache/hop/core/variables/resolver/vault/VaultVariableResolverTest.java
 
b/plugins/tech/vault/src/test/java/org/apache/hop/core/variables/resolver/vault/VaultVariableResolverTest.java
new file mode 100644
index 0000000000..8348e83d83
--- /dev/null
+++ 
b/plugins/tech/vault/src/test/java/org/apache/hop/core/variables/resolver/vault/VaultVariableResolverTest.java
@@ -0,0 +1,359 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.core.variables.resolver.vault;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import io.github.jopenlibs.vault.Vault;
+import io.github.jopenlibs.vault.VaultConfig;
+import io.github.jopenlibs.vault.VaultException;
+import io.github.jopenlibs.vault.api.Logical;
+import io.github.jopenlibs.vault.response.AuthResponse;
+import io.github.jopenlibs.vault.response.LogicalResponse;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Locale;
+import java.util.Map;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.variables.Variables;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+class VaultVariableResolverTest {
+
+  private VaultVariableResolver resolver;
+  private IVariables variables;
+  private Vault vault;
+  private Logical logical;
+
+  @TempDir Path tempDir;
+
+  @BeforeAll
+  static void initHopEnvironment() {
+    HopLogStore.init();
+  }
+
+  @BeforeEach
+  void setUp() throws Exception {
+    vault = mock(Vault.class);
+    logical = mock(Logical.class);
+    LogicalResponse response = mock(LogicalResponse.class);
+    when(vault.logical()).thenReturn(logical);
+    when(logical.read(anyString())).thenReturn(response);
+    when(response.getData()).thenReturn(Map.of("data", 
"{\"password\":\"secret\"}"));
+
+    resolver =
+        new VaultVariableResolver() {
+          @Override
+          protected Vault createVault(VaultConfig vaultConfig) {
+            return vault;
+          }
+        };
+    resolver.setVaultAddress("http://vault:8200";);
+    resolver.setVaultToken("s.token");
+    variables = new Variables();
+  }
+
+  @Test
+  void testPluginMetadata() {
+    assertEquals("Vault-Variable-Resolver", resolver.getPluginId());
+    assertEquals("Hashicorp Vault Variable Resolver", 
resolver.getPluginName());
+  }
+
+  @Test
+  void testDefaultAuthenticationTypeIsToken() {
+    assertEquals(VaultAuthType.TOKEN.name(), new 
VaultVariableResolver().getAuthenticationType());
+  }
+
+  @ParameterizedTest
+  @NullAndEmptySource
+  void testEmptyAuthTypeFallsBackToToken(String authType) throws Exception {
+    assertEquals(VaultAuthType.TOKEN, resolver.parseAuthType(authType));
+  }
+
+  @Test
+  void testAuthTypeIsCaseInsensitive() throws Exception {
+    assertEquals(VaultAuthType.KUBERNETES, 
resolver.parseAuthType("kubernetes"));
+    assertEquals(VaultAuthType.TOKEN, resolver.parseAuthType("Token"));
+  }
+
+  @Test
+  void testAuthTypeParseUsesRootLocale() throws Exception {
+    Locale previous = Locale.getDefault();
+    try {
+      Locale.setDefault(Locale.forLanguageTag("tr-TR"));
+      assertEquals(VaultAuthType.KUBERNETES, 
resolver.parseAuthType("kubernetes"));
+    } finally {
+      Locale.setDefault(previous);
+    }
+  }
+
+  @Test
+  void testUnknownAuthTypeIsRejected() {
+    HopException e = assertThrows(HopException.class, () -> 
resolver.parseAuthType("APPROLE"));
+    assertTrue(e.getMessage().contains("TOKEN"));
+    assertTrue(e.getMessage().contains("KUBERNETES"));
+  }
+
+  @ParameterizedTest
+  @NullAndEmptySource
+  void testResolveWithoutSecretPath(String secretPath) throws Exception {
+    assertNull(resolver.resolve(secretPath, variables));
+  }
+
+  @Test
+  void testTokenAuthReadsSecret() throws Exception {
+    assertEquals("{\"password\":\"secret\"}", 
resolver.resolve("secret/data/hop", variables));
+    verify(logical).read("secret/data/hop");
+  }
+
+  @Test
+  void testPathPrefixIsPrepended() throws Exception {
+    resolver.setPathPrefix("secret/data/");
+    resolver.resolve("hop", variables);
+    verify(logical).read("secret/data/hop");
+  }
+
+  @Test
+  void testTokenAuthWithoutTokenFails() throws Exception {
+    resolver.setVaultToken("");
+    assertNull(resolver.resolve("secret/data/hop", variables));
+  }
+
+  @Test
+  void testKubernetesAuthWithoutRoleFails() throws Exception {
+    resolver.setAuthenticationType(VaultAuthType.KUBERNETES.name());
+    resolver.setKubernetesRole("");
+    resolver.setKubernetesJwt("header.payload.sig");
+    assertNull(resolver.resolve("secret/data/hop", variables));
+  }
+
+  @Test
+  void testLoadJwtFromInlineString() throws Exception {
+    resolver.setKubernetesJwt("  inline-jwt  ");
+    assertEquals("inline-jwt", resolver.loadServiceAccountJwt(variables));
+  }
+
+  @Test
+  void testLoadJwtFromFile() throws Exception {
+    Path jwtFile = tempDir.resolve("token");
+    Files.writeString(jwtFile, "file-jwt\n");
+    resolver.setKubernetesJwt("");
+    resolver.setKubernetesJwtPath(jwtFile.toString());
+    assertEquals("file-jwt", resolver.loadServiceAccountJwt(variables));
+  }
+
+  @Test
+  void testLoadJwtPrefersInlineOverFile() throws Exception {
+    Path jwtFile = tempDir.resolve("token");
+    Files.writeString(jwtFile, "file-jwt");
+    resolver.setKubernetesJwt("inline-jwt");
+    resolver.setKubernetesJwtPath(jwtFile.toString());
+    assertEquals("inline-jwt", resolver.loadServiceAccountJwt(variables));
+  }
+
+  @Test
+  void testLoadJwtUsesDefaultPathWhenEmpty() {
+    resolver.setKubernetesJwt("");
+    resolver.setKubernetesJwtPath("");
+    HopException e =
+        assertThrows(HopException.class, () -> 
resolver.loadServiceAccountJwt(variables));
+    
assertTrue(e.getMessage().contains(BaseVaultVariableResolver.DEFAULT_KUBERNETES_JWT_PATH));
+  }
+
+  @Test
+  void testJwtAndRoleVariablesAreResolved() throws Exception {
+    variables.setVariable("ROLE", "hop");
+    variables.setVariable("JWT", "resolved-jwt");
+    resolver.setKubernetesRole("${ROLE}");
+    resolver.setKubernetesJwt("${JWT}");
+    assertEquals("resolved-jwt", resolver.loadServiceAccountJwt(variables));
+    assertEquals("hop", variables.resolve(resolver.getKubernetesRole()));
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {"", "kubernetes"})
+  void testDefaultKubernetesLoginPath(String mount) {
+    assertEquals("auth/kubernetes", 
BaseVaultVariableResolver.kubernetesLoginPath(mount));
+  }
+
+  @Test
+  void testCustomKubernetesLoginPath() {
+    assertEquals("auth/k8s", 
BaseVaultVariableResolver.kubernetesLoginPath("k8s"));
+    assertEquals("auth/k8s", 
BaseVaultVariableResolver.kubernetesLoginPath("auth/k8s"));
+  }
+
+  @Test
+  void testClientIsCachedAcrossResolves() throws Exception {
+    CountingResolver counting = new CountingResolver(vault);
+    counting.setVaultAddress("http://vault:8200";);
+    counting.setVaultToken("s.token");
+
+    counting.resolve("secret/data/hop", variables);
+    counting.resolve("secret/data/hop", variables);
+
+    assertEquals(1, counting.builds);
+    verify(logical, times(2)).read("secret/data/hop");
+  }
+
+  @Test
+  void testExpiredTokenRebuildsTheClient() throws Exception {
+    CountingResolver counting = new CountingResolver(vault);
+    counting.setVaultAddress("http://vault:8200";);
+    counting.setVaultToken("s.token");
+
+    counting.resolve("secret/data/hop", variables);
+    counting.expireCachedToken();
+    counting.resolve("secret/data/hop", variables);
+
+    assertEquals(2, counting.builds);
+  }
+
+  @Test
+  void testNonRenewableTokenRefreshesBeforeExpiry() throws Exception {
+    CountingResolver counting = new CountingResolver(vault);
+    counting.setVaultAddress("http://vault:8200";);
+    counting.setVaultToken("s.token");
+
+    counting.resolve("secret/data/hop", variables);
+    counting.setCachedLeaseForTest(System.currentTimeMillis() + 10_000L, 
false);
+    counting.resolve("secret/data/hop", variables);
+
+    assertEquals(2, counting.builds);
+  }
+
+  @Test
+  void testKubernetesAuthFailureRetriesOnce() throws Exception {
+    when(logical.read(anyString()))
+        .thenThrow(new VaultException("permission denied", 403))
+        .thenReturn(response("{\"password\":\"secret\"}"));
+
+    CountingResolver kubernetes = new CountingResolver(vault, true);
+    kubernetes.setVaultAddress("http://vault:8200";);
+    kubernetes.setAuthenticationType(VaultAuthType.KUBERNETES.name());
+    kubernetes.setKubernetesRole("hop");
+    kubernetes.setKubernetesJwt("header.payload.sig");
+
+    assertEquals("{\"password\":\"secret\"}", 
kubernetes.resolve("secret/data/hop", variables));
+    assertEquals(2, kubernetes.builds);
+    verify(logical, times(2)).read("secret/data/hop");
+  }
+
+  @Test
+  void testTokenAuthFailureDoesNotRetry() throws Exception {
+    when(logical.read(anyString())).thenThrow(new VaultException("permission 
denied", 403));
+
+    CountingResolver counting = new CountingResolver(vault);
+    counting.setVaultAddress("http://vault:8200";);
+    counting.setVaultToken("s.token");
+
+    assertNull(counting.resolve("secret/data/hop", variables));
+    assertEquals(1, counting.builds);
+    verify(logical, times(1)).read("secret/data/hop");
+  }
+
+  @Test
+  void testClearUnusedCredentialsDropsVaultTokenForKubernetes() {
+    resolver.setAuthenticationType(VaultAuthType.KUBERNETES.name());
+    resolver.setVaultToken("s.leftover");
+    resolver.setKubernetesJwt("inline-jwt");
+    resolver.clearUnusedCredentials();
+    assertEquals("", resolver.getVaultToken());
+    assertEquals("inline-jwt", resolver.getKubernetesJwt());
+  }
+
+  @Test
+  void testClearUnusedCredentialsDropsJwtForToken() {
+    resolver.setAuthenticationType(VaultAuthType.TOKEN.name());
+    resolver.setVaultToken("s.token");
+    resolver.setKubernetesJwt("inline-jwt");
+    resolver.clearUnusedCredentials();
+    assertEquals("s.token", resolver.getVaultToken());
+    assertEquals("", resolver.getKubernetesJwt());
+  }
+
+  @Test
+  void testClearUnusedCredentialsKeepsBothWhenAuthTypeIsAVariable() {
+    resolver.setAuthenticationType("${VAULT_AUTH_TYPE}");
+    resolver.setVaultToken("s.token");
+    resolver.setKubernetesJwt("inline-jwt");
+    resolver.clearUnusedCredentials();
+    assertEquals("s.token", resolver.getVaultToken());
+    assertEquals("inline-jwt", resolver.getKubernetesJwt());
+  }
+
+  private static LogicalResponse response(String data) {
+    LogicalResponse logicalResponse = mock(LogicalResponse.class);
+    when(logicalResponse.getData()).thenReturn(Map.of("data", data));
+    return logicalResponse;
+  }
+
+  private static final class CountingResolver extends VaultVariableResolver {
+    private final Vault vault;
+    private final boolean kubernetesLogin;
+    private int builds;
+
+    private CountingResolver(Vault vault) {
+      this(vault, false);
+    }
+
+    private CountingResolver(Vault vault, boolean kubernetesLogin) {
+      this.vault = vault;
+      this.kubernetesLogin = kubernetesLogin;
+    }
+
+    @Override
+    protected Vault buildVault(IVariables variables) throws HopException {
+      builds++;
+      return super.buildVault(variables);
+    }
+
+    @Override
+    protected Vault createVault(VaultConfig vaultConfig) {
+      return vault;
+    }
+
+    @Override
+    protected AuthResponse loginByKubernetes(Vault vault, String role, String 
jwt, String loginPath)
+        throws HopException {
+      if (!kubernetesLogin) {
+        return super.loginByKubernetes(vault, role, jwt, loginPath);
+      }
+      AuthResponse response = mock(AuthResponse.class);
+      when(response.getAuthClientToken()).thenReturn("s.k8s");
+      when(response.getAuthLeaseDuration()).thenReturn(3600L);
+      when(response.isAuthRenewable()).thenReturn(false);
+      return response;
+    }
+  }
+}
diff --git 
a/plugins/tech/vault/src/test/java/org/apache/hop/core/variables/resolver/vault/VaultWidgetVisibilityTest.java
 
b/plugins/tech/vault/src/test/java/org/apache/hop/core/variables/resolver/vault/VaultWidgetVisibilityTest.java
new file mode 100644
index 0000000000..8608d4be9e
--- /dev/null
+++ 
b/plugins/tech/vault/src/test/java/org/apache/hop/core/variables/resolver/vault/VaultWidgetVisibilityTest.java
@@ -0,0 +1,211 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.core.variables.resolver.vault;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+import org.apache.hop.core.gui.plugin.GuiRegistry;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.core.variables.resolver.VariableResolver;
+import org.apache.hop.ui.core.gui.GuiCompositeWidgets;
+import org.apache.hop.ui.core.widget.TextVar;
+import org.apache.hop.ui.hopgui.HopGuiEnvironment;
+import org.apache.hop.ui.testing.SwtBotTestBase;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swt.widgets.Text;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+/**
+ * TOKEN and KUBERNETES have no credentials in common, so the editor only 
shows the ones the chosen
+ * type can actually use.
+ */
+@Tag("uitest")
+class VaultWidgetVisibilityTest extends SwtBotTestBase {
+
+  @BeforeAll
+  static void registerGuiPluginElements() throws Exception {
+    if (GuiRegistry.getInstance()
+            .findGuiElements(
+                VaultVariableResolver.class.getName(),
+                VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID)
+        == null) {
+      HopGuiEnvironment.init();
+    }
+  }
+
+  @Test
+  @DisplayName("TOKEN shows the Vault token and hides Kubernetes fields")
+  void tokenShowsOnlyTheToken() {
+    withWidgets(
+        resolver -> resolver.setAuthenticationType(VaultAuthType.TOKEN.name()),
+        (resolver, widgets) -> {
+          assertVisible(widgets, 
BaseVaultVariableResolver.ID_AUTHENTICATION_TYPE);
+          assertVisible(widgets, BaseVaultVariableResolver.ID_VAULT_TOKEN);
+
+          assertHidden(widgets, BaseVaultVariableResolver.ID_KUBERNETES_ROLE);
+          assertHidden(widgets, 
BaseVaultVariableResolver.ID_KUBERNETES_JWT_PATH);
+          assertHidden(widgets, BaseVaultVariableResolver.ID_KUBERNETES_JWT);
+          assertHidden(widgets, 
BaseVaultVariableResolver.ID_KUBERNETES_AUTH_PATH);
+        });
+  }
+
+  @Test
+  @DisplayName("KUBERNETES shows the Kubernetes fields and hides the Vault 
token")
+  void kubernetesShowsOnlyKubernetesFields() {
+    withWidgets(
+        resolver -> 
resolver.setAuthenticationType(VaultAuthType.KUBERNETES.name()),
+        (resolver, widgets) -> {
+          assertVisible(widgets, 
BaseVaultVariableResolver.ID_AUTHENTICATION_TYPE);
+          assertVisible(widgets, BaseVaultVariableResolver.ID_KUBERNETES_ROLE);
+          assertVisible(widgets, 
BaseVaultVariableResolver.ID_KUBERNETES_JWT_PATH);
+          assertVisible(widgets, BaseVaultVariableResolver.ID_KUBERNETES_JWT);
+          assertVisible(widgets, 
BaseVaultVariableResolver.ID_KUBERNETES_AUTH_PATH);
+
+          assertHidden(widgets, BaseVaultVariableResolver.ID_VAULT_TOKEN);
+        });
+  }
+
+  @Test
+  @DisplayName("switching to KUBERNETES clears a leftover Vault token from the 
hidden widget")
+  void kubernetesClearsLeftoverVaultToken() {
+    withWidgets(
+        resolver -> {
+          resolver.setAuthenticationType(VaultAuthType.KUBERNETES.name());
+          resolver.setVaultToken("s.leftover");
+        },
+        (resolver, widgets) -> {
+          resolver.persistContents(widgets);
+          assertEquals("", resolver.getVaultToken());
+          assertEquals("", textOf(widgets, 
BaseVaultVariableResolver.ID_VAULT_TOKEN));
+        });
+  }
+
+  @Test
+  @DisplayName("a variable auth type keeps every credential field visible and 
intact")
+  void variableAuthTypeShowsAllCredentialFields() {
+    withWidgets(
+        resolver -> {
+          resolver.setAuthenticationType("${VAULT_AUTH_TYPE}");
+          resolver.setVaultToken("s.token");
+          resolver.setKubernetesJwt("inline-jwt");
+          resolver.setKubernetesRole("hop");
+        },
+        (resolver, widgets) -> {
+          assertVisible(widgets, BaseVaultVariableResolver.ID_VAULT_TOKEN);
+          assertVisible(widgets, BaseVaultVariableResolver.ID_KUBERNETES_ROLE);
+          assertVisible(widgets, 
BaseVaultVariableResolver.ID_KUBERNETES_JWT_PATH);
+          assertVisible(widgets, BaseVaultVariableResolver.ID_KUBERNETES_JWT);
+          assertVisible(widgets, 
BaseVaultVariableResolver.ID_KUBERNETES_AUTH_PATH);
+
+          resolver.persistContents(widgets);
+          assertEquals("s.token", resolver.getVaultToken());
+          assertEquals("inline-jwt", resolver.getKubernetesJwt());
+          assertEquals("hop", resolver.getKubernetesRole());
+        });
+  }
+
+  @Test
+  @DisplayName("connection and secret options stay visible for every auth 
type")
+  void generalOptionsStayVisibleForEveryAuthType() {
+    for (VaultAuthType authType : VaultAuthType.values()) {
+      withWidgets(
+          resolver -> resolver.setAuthenticationType(authType.name()),
+          (resolver, widgets) -> {
+            assertVisible(widgets, BaseVaultVariableResolver.ID_VAULT_ADDRESS);
+            assertVisible(widgets, BaseVaultVariableResolver.ID_NAMESPACE);
+            assertVisible(widgets, BaseVaultVariableResolver.ID_VERIFYING_SSL);
+            assertVisible(widgets, BaseVaultVariableResolver.ID_PEM_FILE_PATH);
+            assertVisible(widgets, BaseVaultVariableResolver.ID_PEM_STRING);
+            assertVisible(widgets, BaseVaultVariableResolver.ID_OPEN_TIMEOUT);
+            assertVisible(widgets, BaseVaultVariableResolver.ID_READ_TIMEOUT);
+            assertVisible(widgets, BaseVaultVariableResolver.ID_PATH_PREFIX);
+            assertVisible(widgets, 
BaseVaultVariableResolver.ID_AUTHENTICATION_TYPE);
+          });
+    }
+  }
+
+  private void withWidgets(
+      Consumer<VaultVariableResolver> configure,
+      BiConsumer<VaultVariableResolver, GuiCompositeWidgets> assertions) {
+    ensureDisplay();
+
+    Shell shell = new Shell(display);
+    shell.setLayout(new FormLayout());
+    try {
+      VaultVariableResolver resolver = new VaultVariableResolver();
+      configure.accept(resolver);
+
+      GuiCompositeWidgets widgets = new GuiCompositeWidgets(new Variables());
+      widgets.createCompositeWidgets(
+          resolver, null, shell, 
VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID, null);
+      widgets.setWidgetsContents(resolver, shell, 
VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID);
+      widgets.setWidgetsListener(resolver);
+      resolver.widgetsPopulated(widgets);
+
+      shell.layout(true, true);
+      shell.pack();
+
+      assertions.accept(resolver, widgets);
+    } finally {
+      if (!shell.isDisposed()) {
+        shell.dispose();
+      }
+    }
+  }
+
+  private void assertVisible(GuiCompositeWidgets widgets, String id) {
+    Control control = widgets.getWidgetsMap().get(id);
+    assertNotNull(control, "no widget registered for " + id);
+    assertTrue(control.getVisible(), id + " should be visible");
+    Control label = widgets.getLabelsMap().get(id);
+    if (label != null) {
+      assertTrue(label.getVisible(), "the label of " + id + " should be 
visible");
+    }
+  }
+
+  private void assertHidden(GuiCompositeWidgets widgets, String id) {
+    Control control = widgets.getWidgetsMap().get(id);
+    assertNotNull(control, "no widget registered for " + id);
+    assertFalse(control.getVisible(), id + " should be hidden");
+    Control label = widgets.getLabelsMap().get(id);
+    if (label != null) {
+      assertFalse(label.getVisible(), "the label of " + id + " should be 
hidden");
+    }
+  }
+
+  private static String textOf(GuiCompositeWidgets widgets, String id) {
+    Control control = widgets.getWidgetsMap().get(id);
+    if (control instanceof TextVar textVar) {
+      return textVar.getText();
+    }
+    if (control instanceof Text text) {
+      return text.getText();
+    }
+    return null;
+  }
+}
diff --git 
a/ui/src/main/java/org/apache/hop/ui/core/variables/resolver/VariableResolverEditor.java
 
b/ui/src/main/java/org/apache/hop/ui/core/variables/resolver/VariableResolverEditor.java
index c6e54800da..493595d472 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/core/variables/resolver/VariableResolverEditor.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/core/variables/resolver/VariableResolverEditor.java
@@ -337,6 +337,9 @@ public class VariableResolverEditor extends 
MetadataEditor<VariableResolver> {
     //
     guiCompositeWidgets.getWidgetsContents(
         meta.getIResolver(), VariableResolver.GUI_PLUGIN_ELEMENT_PARENT_ID);
+    if (guiCompositeWidgets.getWidgetsListener() != null) {
+      
guiCompositeWidgets.getWidgetsListener().persistContents(guiCompositeWidgets);
+    }
   }
 
   private String[] getResolverTypes() {

Reply via email to