From 9cd426a9e8bf27e60aa2b8b70afceafcaabcdba6 Mon Sep 17 00:00:00 2001
From: Andrey Zvonov <32552679+zvonand@users.noreply.github.com>
Date: Sat, 28 Feb 2026 21:17:56 +0300
Subject: [PATCH 1/2] Merge pull request #1430 from
 Altinity/backports/antalya-26.1/1078

26.1 Antalya: Token Authentication and Authorization

Source-PR: #1430 (https://github.com/Altinity/ClickHouse/pull/1430)
---
 .../aspell-ignore/en/aspell-dict.txt          |   7 +
 .../external-authenticators/index.md          |   3 +-
 .../external-authenticators/tokens.md         | 278 ++++++++
 src/Access/AccessControl.cpp                  |  40 +-
 src/Access/AccessControl.h                    |   7 +
 src/Access/Authentication.cpp                 |  17 +-
 src/Access/AuthenticationData.cpp             |  26 +-
 src/Access/AuthenticationData.h               |   8 +
 src/Access/Common/JWKSProvider.cpp            | 113 ++++
 src/Access/Common/JWKSProvider.h              |  73 +++
 src/Access/Credentials.cpp                    |   5 +
 src/Access/Credentials.h                      |  45 ++
 src/Access/ExternalAuthenticators.cpp         | 176 +++++-
 src/Access/ExternalAuthenticators.h           |  28 +-
 src/Access/IAccessStorage.cpp                 |   5 +
 src/Access/TokenAccessStorage.cpp             | 591 ++++++++++++++++++
 src/Access/TokenAccessStorage.h               |  88 +++
 src/Access/TokenProcessors.h                  | 230 +++++++
 src/Access/TokenProcessorsJWT.cpp             | 440 +++++++++++++
 src/Access/TokenProcessorsOpaque.cpp          | 402 ++++++++++++
 src/Access/TokenProcessorsParse.cpp           | 139 ++++
 src/Access/UsersConfigParser.cpp              |  16 +-
 src/Core/ServerSettings.cpp                   |   9 +
 src/Parsers/Access/ASTAuthenticationData.cpp  |   7 +-
 src/Parsers/Access/ASTCreateUserQuery.h       |   4 +-
 src/Parsers/Access/ParserCreateUserQuery.cpp  |  14 +
 src/Parsers/Access/ParserCreateUserQuery.h    |   4 +-
 src/Parsers/CommonParsers.h                   |   1 +
 src/Server/HTTP/authenticateUserByHTTP.cpp    |  24 +-
 src/Server/TCPHandler.cpp                     |  22 +
 src/Server/TCPHandler.h                       |   1 +
 .../System/StorageSystemBuildOptions.cpp.in   |   1 +
 tests/integration/test_jwt_auth/__init__.py   |   0
 .../test_jwt_auth/configs/users.xml           |  15 +
 .../test_jwt_auth/configs/validators.xml      |  26 +
 .../helpers/generate_private_key.py           |  21 +
 .../test_jwt_auth/helpers/jwt_jwk.py          | 113 ++++
 .../helpers/jwt_static_secret.py              |  43 ++
 .../test_jwt_auth/helpers/private_key_1       |  27 +
 .../test_jwt_auth/helpers/private_key_2       |  27 +
 .../test_jwt_auth/jwks_server/server.py       |  33 +
 tests/integration/test_jwt_auth/test.py       |  82 +++
 42 files changed, 3192 insertions(+), 19 deletions(-)
 create mode 100644 docs/en/operations/external-authenticators/tokens.md
 create mode 100644 src/Access/Common/JWKSProvider.cpp
 create mode 100644 src/Access/Common/JWKSProvider.h
 create mode 100644 src/Access/TokenAccessStorage.cpp
 create mode 100644 src/Access/TokenAccessStorage.h
 create mode 100644 src/Access/TokenProcessors.h
 create mode 100644 src/Access/TokenProcessorsJWT.cpp
 create mode 100644 src/Access/TokenProcessorsOpaque.cpp
 create mode 100644 src/Access/TokenProcessorsParse.cpp
 create mode 100644 tests/integration/test_jwt_auth/__init__.py
 create mode 100644 tests/integration/test_jwt_auth/configs/users.xml
 create mode 100644 tests/integration/test_jwt_auth/configs/validators.xml
 create mode 100644 tests/integration/test_jwt_auth/helpers/generate_private_key.py
 create mode 100644 tests/integration/test_jwt_auth/helpers/jwt_jwk.py
 create mode 100644 tests/integration/test_jwt_auth/helpers/jwt_static_secret.py
 create mode 100644 tests/integration/test_jwt_auth/helpers/private_key_1
 create mode 100644 tests/integration/test_jwt_auth/helpers/private_key_2
 create mode 100644 tests/integration/test_jwt_auth/jwks_server/server.py
 create mode 100644 tests/integration/test_jwt_auth/test.py

diff --git a/ci/jobs/scripts/check_style/aspell-ignore/en/aspell-dict.txt b/ci/jobs/scripts/check_style/aspell-ignore/en/aspell-dict.txt
index 47883b628cf0..2c0c572f3306 100644
--- a/ci/jobs/scripts/check_style/aspell-ignore/en/aspell-dict.txt
+++ b/ci/jobs/scripts/check_style/aspell-ignore/en/aspell-dict.txt
@@ -301,6 +301,8 @@ DoubleDelta
 Doxygen
 Dresseler
 Durre
+ECDSA
+EdDSA
 ECMA
 ETag
 EachRow
@@ -563,6 +565,8 @@ JoinAlgorithm
 JoinStrictness
 JumpConsistentHash
 Jupyter
+jwks
+JWKS
 KDevelop
 KafkaAssignedPartitions
 KafkaBackgroundReads
@@ -3516,6 +3520,7 @@ uuid
 uuids
 uuidv
 vCPU
+validators
 varPop
 varPopStable
 varSamp
@@ -3534,6 +3539,8 @@ vectorscan
 vendoring
 verificationDepth
 verificationMode
+verifier
+verifiers
 versionedcollapsingmergetree
 vhost
 virtualized
diff --git a/docs/en/operations/external-authenticators/index.md b/docs/en/operations/external-authenticators/index.md
index 037fa8c1054f..3a61522bc283 100644
--- a/docs/en/operations/external-authenticators/index.md
+++ b/docs/en/operations/external-authenticators/index.md
@@ -19,4 +19,5 @@ The following external authenticators and directories are supported:
 - [LDAP](/operations/external-authenticators/ldap#ldap-external-authenticator) [Authenticator](./ldap.md#ldap-external-authenticator) and [Directory](./ldap.md#ldap-external-user-directory)
 - Kerberos [Authenticator](/operations/external-authenticators/kerberos#kerberos-as-an-external-authenticator-for-existing-users)
 - [SSL X.509 authentication](/operations/external-authenticators/ssl-x509)
-- HTTP [Authenticator](./http.md)
\ No newline at end of file
+- HTTP [Authenticator](./http.md)
+- Token-based [Authenticator](./tokens.md)
diff --git a/docs/en/operations/external-authenticators/tokens.md b/docs/en/operations/external-authenticators/tokens.md
new file mode 100644
index 000000000000..48c86c92b9cf
--- /dev/null
+++ b/docs/en/operations/external-authenticators/tokens.md
@@ -0,0 +1,278 @@
+---
+slug: /en/operations/external-authenticators/oauth
+title: "Token-based authentication"
+---
+import SelfManaged from '@site/docs/en/_snippets/_self_managed_only_no_roadmap.md';
+
+<SelfManaged />
+
+ClickHouse users can be authenticated using tokens. This works in two ways:
+
+- An existing user (defined in `users.xml` or in local access control paths) can be authenticated with a token if this user can be `IDENTIFIED WITH jwt`. 
+- Use the information from the token or from an external Identity Provider (IdP) as a source of user definitions and allow locally undefined users to be authenticated with a valid token.
+
+Although not all tokens are JWTs, under the hood both ways are treated as the same authentication method to maintain better compatibility.
+
+# Token Processors
+
+## Configuration
+
+Token-based authentication is enabled by default. To disable it, set `enable_token_auth` to `0` in `config.xml`:
+
+```xml
+<enable_token_auth>0</enable_token_auth>
+```
+
+When disabled, token processors are not parsed, TokenAccessStorage is not available, and authentication via tokens (`--jwt` option or `Authorization: Bearer` header) is rejected.
+
+To use token-based authentication, add `token_processors` section to `config.xml` and define at least one token processor in it.
+Its contents are different for different token processor types.
+
+**Common parameters**
+- `type` -- type of token processor. Supported values: "jwt_static_key", "jwt_static_jwks", "jwt_dynamic_jwks", "azure", "openid". Mandatory. Case-insensitive.
+- `token_cache_lifetime` -- maximum lifetime of cached token (in seconds). Optional, default: 3600.
+- `username_claim` -- name of claim (field) that will be treated as ClickHouse username. Optional, default: "sub".
+- `groups_claim` -- name of claim (field) that contains list of groups user belongs to. This claim will be looked up in the token itself (in case token is a valid JWT, e.g. in Keycloak) or in response from `/userinfo`. Optional, default: "groups".
+
+For each type, there are additional specific parameters (some of them are mandatory).
+If some parameters that are not required for current processor type are specified, they are ignored. 
+
+## JWT (JSON Web Token)
+
+JWT itself is a source of information about user.
+It is decoded locally and its integrity is verified using either a local static key or JWKS (JSON Web Key Set), local or remote.
+
+### JWT with static key:
+```xml
+<clickhouse>
+    <token_processors>
+        <my_static_key_validator>
+          <type>jwt_static_key</type>
+          <algo>HS256</algo>
+          <static_key>my_static_secret</static_key>
+        </my_static_key_validator>
+    </token_processors>
+</clickhouse>
+```
+**Parameters:**
+- `algo` - Algorithm for signature validation. Mandatory. Supported values:
+
+  | HMAC  | RSA   | ECDSA  | PSS   | EdDSA   |
+    |-------| ----- | ------ | ----- | ------- |
+  | HS256 | RS256 | ES256  | PS256 | Ed25519 |
+  | HS384 | RS384 | ES384  | PS384 | Ed448   |
+  | HS512 | RS512 | ES512  | PS512 |         |
+  |       |       | ES256K |       |         |
+  Also supports None (not recommended and must *NEVER* be used in production).
+- `claims` - A string containing a JSON object that should be contained in the token payload. If this parameter is defined, token without corresponding payload will be considered invalid. Optional.
+- `static_key` - key for symmetric algorithms. Mandatory for `HS*` family algorithms.
+- `static_key_in_base64` - indicates if the `static_key` key is base64-encoded. Optional, default: `False`.
+- `public_key` - public key for asymmetric algorithms. Mandatory except for `HS*` family algorithms and `None`.
+- `private_key` - private key for asymmetric algorithms. Optional.
+- `public_key_password` - public key password. Optional.
+- `private_key_password` - private key password. Optional.
+- `expected_issuer` - Expected value of the `iss` (issuer) claim in the JWT. If specified, tokens with a different issuer will be rejected. Optional.
+- `expected_audience` - Expected value of the `aud` (audience) claim in the JWT. If specified, tokens with a different audience will be rejected. Optional.
+- `allow_no_expiration` - If `true`, tokens without the `exp` (expiration) claim are accepted. Otherwise they are rejected. Optional, default: `false`.
+
+### JWT with static JWKS
+```xml
+<clickhouse>
+    <token_processors>
+        <my_static_jwks_validator>
+          <type>jwt_static_jwks</type>
+          <static_jwks>{"keys": [{"kty": "RSA", "alg": "RS256", "kid": "mykid", "n": "_public_key_mod_", "e": "AQAB"}]}</static_jwks>
+        </my_static_jwks_validator>
+    </token_processors>
+</clickhouse>
+```
+
+**Parameters:**
+
+- `static_jwks` - content of JWKS in JSON
+- `static_jwks_file` - path to a file with JWKS
+- `claims` - A string containing a JSON object that should be contained in the token payload. If this parameter is defined, token without corresponding payload will be considered invalid. Optional.
+- `verifier_leeway` - Clock skew tolerance (seconds). Useful for handling small differences in system clocks between ClickHouse and the token issuer. Optional.
+- `expected_issuer` - Expected value of the `iss` (issuer) claim in the JWT. If specified, tokens with a different issuer will be rejected. Optional.
+- `expected_audience` - Expected value of the `aud` (audience) claim in the JWT. If specified, tokens with a different audience will be rejected. Optional.
+- `allow_no_expiration` - If `true`, tokens without the `exp` (expiration) claim are accepted. Otherwise they are rejected. Optional, default: `false`.
+
+:::note
+Only one of `static_jwks` or `static_jwks_file` keys must be present in one verifier
+:::
+
+:::note
+Only RS* family algorithms are supported!
+:::
+
+### JWT with remote JWKS
+```xml
+<clickhouse>
+    <token_processors>
+        <basic_auth_server>
+          <type>jwt_dynamic_jwks</type>
+          <jwks_uri>http://localhost:8000/.well-known/jwks.json</jwks_uri>
+          <jwks_cache_lifetime>3600</jwks_cache_lifetime>
+        </basic_auth_server>
+    </token_processors>
+</clickhouse>
+```
+
+**Parameters:**
+
+- `uri` - JWKS endpoint. Mandatory.
+- `jwks_cache_lifetime` - Period for resend request for refreshing JWKS. Optional, default: 3600.
+- `claims` - A string containing a JSON object that should be contained in the token payload. If this parameter is defined, token without corresponding payload will be considered invalid. Optional.
+- `verifier_leeway` - Clock skew tolerance (seconds). Useful for handling small differences in system clocks between ClickHouse and the token issuer. Optional.
+- `expected_issuer` - Expected value of the `iss` (issuer) claim in the JWT. If specified, tokens with a different issuer will be rejected. Optional.
+- `expected_audience` - Expected value of the `aud` (audience) claim in the JWT. If specified, tokens with a different audience will be rejected. Optional.
+- `allow_no_expiration` - If `true`, tokens without the `exp` (expiration) claim are accepted. Otherwise they are rejected. Optional, default: `false`.
+
+
+## Processors with external providers
+
+Some tokens cannot be decoded and validated locally. External service is needed in this case. "Azure" and "OpenID" (a generic type) are supported now.
+
+### Azure
+```xml
+<clickhouse>
+    <token_processors>
+        <azure_processor>
+          <type>azure</type>
+        </azure_processor>
+    </token_processors>
+</clickhouse>
+```
+
+No additional parameters are required.
+
+### OpenID
+```xml
+<clickhouse>
+    <token_processors>
+        <oid_processor_1>
+          <type>openid</type>
+          <configuration_endpoint>url/.well-known/openid-configuration</configuration_endpoint>
+          <verifier_leeway>60</verifier_leeway>
+          <jwks_cache_lifetime>3600</jwks_cache_lifetime>
+        </oid_processor_1>
+        <oid_processor_2>
+          <type>openid</type>
+          <userinfo_endpoint>url/userinfo</userinfo_endpoint>
+          <token_introspection_endpoint>url/tokeninfo</token_introspection_endpoint>
+          <jwks_uri>url/.well-known/jwks.json</jwks_uri>
+          <verifier_leeway>60</verifier_leeway>
+          <jwks_cache_lifetime>3600</jwks_cache_lifetime>
+        </oid_processor_2>
+    </token_processors>
+</clickhouse>
+```
+
+:::note
+Either `configuration_endpoint` or both `userinfo_endpoint` and `token_introspection_endpoint` (and, optionally, `jwks_uri`) shall be set. If none of them are set or all three are set, this is an invalid configuration that will not be parsed.
+:::
+
+**Parameters:**
+
+- `configuration_endpoint` - URI of OpenID configuration (often ends with `.well-known/openid-configuration`);
+- `userinfo_endpoint` - URI of endpoint that returns user information in exchange for a valid token;
+- `token_introspection_endpoint` - URI of token introspection endpoint (returns information about a valid token);
+- `jwks_uri` - URI of OpenID configuration (often ends with `.well-known/jwks.json`)
+- `jwks_cache_lifetime` - Period for resend request for refreshing JWKS. Optional, default: 3600.
+- `verifier_leeway` - Clock skew tolerance (seconds). Useful for handling small differences in system clocks between ClickHouse and the token issuer. Optional, default: 60
+- `expected_issuer` - Expected value of the `iss` (issuer) claim in the JWT. If specified, tokens with a different issuer will be rejected. Optional.
+- `expected_audience` - Expected value of the `aud` (audience) claim in the JWT. If specified, tokens with a different audience will be rejected. Optional.
+- `allow_no_expiration` - If `true`, tokens without the `exp` (expiration) claim are accepted. Otherwise they are rejected. Optional, default: `false`.
+
+Sometimes a token is a valid JWT. In that case token will be decoded and validated locally if configuration endpoint returns JWKS URI (or `jwks_uri` is specified alongside `userinfo_endpoint` and `token_introspection_endpoint`).
+
+### Tokens cache
+To reduce number of requests to IdP, tokens are cached internally for a maximum period of `token_cache_lifetime` seconds.
+If token expires sooner than `token_cache_lifetime`, then cache entry for this token will only be valid while token is valid.
+If token lifetime is longer than `token_cache_lifetime`, cache entry for this token will be valid for `token_cache_lifetime`. 
+
+## Enabling token authentication for a user in `users.xml` {#enabling-jwt-auth-in-users-xml}
+
+In order to enable token-based authentication for the user, specify `jwt` section instead of `password` or other similar sections in the user definition.
+
+Parameters:
+- `claims` - An optional string containing a json object that should be contained in the token payload.
+
+Example (goes into `users.xml`):
+```xml
+<clickhouse>
+    <my_user>
+        <jwt>
+            <claims>{"resource_access":{"account": {"roles": ["view-profile"]}}}</claims>
+        </jwt>
+    </my_user>
+</clickhouse>
+```
+
+Here, the JWT payload must contain `["view-profile"]` on path `resource_access.account.roles`, otherwise authentication will not succeed even with a valid JWT.
+
+:::note
+Per-user `claims` are enforced only when the token is a JWT (validated by a JWT processor such as `jwt_static_key` or `jwt_dynamic_jwks`). When the user authenticates with an opaque (access) token (e.g. via Azure, OpenID, or Google token processors), claims are not checked and authentication succeeds if the token is otherwise valid.
+:::
+
+```
+{
+...
+  "resource_access": {
+    "account": {
+      "roles": ["view-profile"]
+    }
+  },
+...
+}
+```
+
+:::note
+A user cannot have JWT authentication together with any other authentication method. The presence of any other sections like `password` alongside `jwt` will force ClickHouse to shut down.
+:::
+
+## Enabling token authentication using SQL {#enabling-jwt-auth-using-sql}
+
+Users with "JWT" authentication type cannot be created using SQL now.
+
+## Identity Provider as an External User Directory {#idp-external-user-directory}
+
+If there is no suitable user pre-defined in ClickHouse, authentication is still possible: Identity Provider can be used as source of user information.
+To allow this, add `token` section to the `users_directories` section of the `config.xml` file.
+
+At each login attempt, ClickHouse tries to find the user definition locally and authenticate it as usual.
+If a token is provided but the user is not defined, ClickHouse will treat the user as externally defined and will try to validate the token and get user information from the specified processor.
+If validated successfully, the user will be considered existing and authenticated. The user will be assigned roles from the list specified in the `roles` section. 
+All this implies that the SQL-driven [Access Control and Account Management](/docs/en/guides/sre/user-management/index.md#access-control) is enabled and roles are created using the [CREATE ROLE](/docs/en/sql-reference/statements/create/role.md#create-role-statement) statement.
+
+**Example**
+
+```xml
+<clickhouse>
+    <user_directories>
+        <token>
+            <processor>token_processor_name</processor>
+            <common_roles>
+                <token_test_role_1 />
+            </common_roles>
+            <default_profile>my_profile</default_profile>
+            <roles_filter>
+                \bclickhouse-[a-zA-Z0-9]+\b
+            </roles_filter>
+            <roles_transform>s/-/_/g</roles_transform>
+        </token>
+    </user_directories>
+</clickhouse>
+```
+
+:::note
+For now, no more than one `token` section can be defined inside `user_directories`. This _may_ change in future.
+:::
+
+**Parameters**
+
+- `processor` — Name of one of processors defined in `token_processors` config section described above. This parameter is mandatory and cannot be empty.
+- `common_roles` — Section with a list of locally defined roles that will be assigned to each user retrieved from the IdP. Optional.
+- `default_profile` — Name of a locally defined settings profile that will be assigned to each user retrieved from the IdP. If the profile does not exist, a warning will be logged and the user will be created without a profile. Optional.
+- `roles_filter` — Regex string for groups filtering. Only groups matching this regex will be mapped to roles. Optional.
+- `roles_transform` — Sed-style transform pattern to apply to group names before mapping to roles. Format: `s/pattern/replacement/flags`. The `g` flag applies the replacement globally (all occurrences). Example: `s/-/_/g` converts `clickhouse-grp-dba` to `clickhouse_grp_dba`. Optional.
diff --git a/src/Access/AccessControl.cpp b/src/Access/AccessControl.cpp
index d7b8f94108ae..10dfc1c29459 100644
--- a/src/Access/AccessControl.cpp
+++ b/src/Access/AccessControl.cpp
@@ -5,6 +5,7 @@
 #include <Access/UsersConfigAccessStorage.h>
 #include <Access/DiskAccessStorage.h>
 #include <Access/LDAPAccessStorage.h>
+#include <Access/TokenAccessStorage.h>
 #include <Access/ContextAccess.h>
 #include <Access/EnabledSettings.h>
 #include <Access/EnabledRolesInfo.h>
@@ -43,6 +44,7 @@ namespace ErrorCodes
     extern const int REQUIRED_PASSWORD;
     extern const int CANNOT_COMPILE_REGEXP;
     extern const int BAD_ARGUMENTS;
+    extern const int INVALID_CONFIG_PARAMETER;
 }
 
 namespace
@@ -292,6 +294,8 @@ void AccessControl::setupFromMainConfig(const Poco::Util::AbstractConfiguration
     setDefaultPasswordTypeFromConfig(config_.getString("default_password_type", "sha256_password"));
     setPasswordComplexityRulesFromConfig(config_);
 
+    setTokenAuthEnabled(config_.getBool("enable_token_auth", true));
+
     setBcryptWorkfactor(config_.getInt("bcrypt_workfactor", 12));
 
     /// Optional improvements in access control system.
@@ -430,6 +434,12 @@ void AccessControl::addLDAPStorage(const String & storage_name_, const Poco::Uti
     LOG_DEBUG(getLogger(), "Added {} access storage '{}', LDAP server name: {}", String(new_storage->getStorageType()), new_storage->getStorageName(), new_storage->getLDAPServerName());
 }
 
+void AccessControl::addTokenStorage(const String & storage_name_, const Poco::Util::AbstractConfiguration & config_, const String & prefix_)
+{
+    auto new_storage = std::make_shared<TokenAccessStorage>(storage_name_, *this, config_, prefix_);
+    addStorage(new_storage);
+    LOG_DEBUG(getLogger(), "Added {} access storage '{}'", String(new_storage->getStorageType()), new_storage->getStorageName());
+}
 
 void AccessControl::addStoragesFromUserDirectoriesConfig(
     const Poco::Util::AbstractConfiguration & config,
@@ -442,6 +452,8 @@ void AccessControl::addStoragesFromUserDirectoriesConfig(
     Strings keys_in_user_directories;
     config.keys(key, keys_in_user_directories);
 
+    bool has_token_storage = false;
+
     for (const String & key_in_user_directories : keys_in_user_directories)
     {
         String prefix = key + "." + key_in_user_directories;
@@ -455,6 +467,8 @@ void AccessControl::addStoragesFromUserDirectoriesConfig(
             type = DiskAccessStorage::STORAGE_TYPE;
         else if (type == "ldap")
             type = LDAPAccessStorage::STORAGE_TYPE;
+        else if (type == "token")
+            type = TokenAccessStorage::STORAGE_TYPE;
 
         String name = config.getString(prefix + ".name", type);
 
@@ -488,6 +502,20 @@ void AccessControl::addStoragesFromUserDirectoriesConfig(
             bool allow_backup = config.getBool(prefix + ".allow_backup", true);
             addReplicatedStorage(name, zookeeper_path, get_zookeeper_function, allow_backup);
         }
+        else if (type == TokenAccessStorage::STORAGE_TYPE)
+        {
+            if (!isTokenAuthEnabled())
+            {
+                LOG_INFO(getLogger(), "Token authentication is disabled, skipping token user directory '{}'", name);
+                continue;
+            }
+
+            if (has_token_storage)
+                throw Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "Only one `token` section can be defined.");
+
+            addTokenStorage(name, config, prefix);
+            has_token_storage = true;
+        }
         else
             throw Exception(ErrorCodes::UNKNOWN_ELEMENT_IN_CONFIG, "Unknown storage type '{}' at {} in config", type, prefix);
     }
@@ -669,7 +697,7 @@ void AccessControl::restoreFromBackup(RestorerFromBackup & restorer, const Strin
 
 void AccessControl::setExternalAuthenticatorsConfig(const Poco::Util::AbstractConfiguration & config)
 {
-    external_authenticators->setConfiguration(config, getLogger());
+    external_authenticators->setConfiguration(config, getLogger(), isTokenAuthEnabled());
 }
 
 
@@ -948,4 +976,14 @@ bool AccessControl::getAllowBetaTierSettings() const
 {
     return allow_beta_tier_settings;
 }
+
+void AccessControl::setTokenAuthEnabled(bool enable)
+{
+    enable_token_auth = enable;
+}
+
+bool AccessControl::isTokenAuthEnabled() const
+{
+    return enable_token_auth;
+}
 }
diff --git a/src/Access/AccessControl.h b/src/Access/AccessControl.h
index 2fa35306d915..71c37d243359 100644
--- a/src/Access/AccessControl.h
+++ b/src/Access/AccessControl.h
@@ -93,6 +93,8 @@ class AccessControl : public MultipleAccessStorage
     /// Adds LDAPAccessStorage which allows querying remote LDAP server for user info.
     void addLDAPStorage(const String & storage_name_, const Poco::Util::AbstractConfiguration & config_, const String & prefix_);
 
+    void addTokenStorage(const String & storage_name_, const Poco::Util::AbstractConfiguration & config_, const String & prefix_);
+
     void addReplicatedStorage(const String & storage_name,
                               const String & zookeeper_path,
                               const zkutil::GetZooKeeper & get_zookeeper_function,
@@ -271,6 +273,10 @@ class AccessControl : public MultipleAccessStorage
     bool getAllowExperimentalTierSettings() const;
     bool getAllowBetaTierSettings() const;
 
+    /// Controls whether token-based auth is enabled.
+    void setTokenAuthEnabled(bool enable);
+    bool isTokenAuthEnabled() const;
+
 private:
     class ContextAccessCache;
     class CustomSettingsPrefixes;
@@ -307,6 +313,7 @@ class AccessControl : public MultipleAccessStorage
     std::atomic_bool enable_user_name_access_type = true;
     std::atomic_bool enable_read_write_grants = false;
     std::atomic_bool allow_impersonate_user = false;
+    std::atomic_bool enable_token_auth = true;
 };
 
 }
diff --git a/src/Access/Authentication.cpp b/src/Access/Authentication.cpp
index 3f6f1bf5e009..786744df45ed 100644
--- a/src/Access/Authentication.cpp
+++ b/src/Access/Authentication.cpp
@@ -329,7 +329,10 @@ Authentication::CredentialsCheckResult Authentication::areCredentialsValid(
     const ClientInfo & client_info,
     SettingsChanges & settings)
 {
-    if (!credentials.isReady())
+    /// It is OK for TokenCredentials to be not ready:
+    /// When auth request happens, we do not even know the username.
+    /// Token is resolved a bit later and the user information will be put in credentials
+    if (!typeid_cast<const TokenCredentials *>(&credentials) && !credentials.isReady())
         return CredentialsCheckResult::Fail;
 
     if (const auto * gss_acceptor_context = typeid_cast<const GSSAcceptorContext *>(&credentials))
@@ -377,6 +380,18 @@ Authentication::CredentialsCheckResult Authentication::areCredentialsValid(
     }
 #endif
 
+    if (const auto * token_credentials = typeid_cast<const TokenCredentials *>(&credentials))
+    {
+        if (authentication_method.getType() != AuthenticationType::JWT)
+            return CredentialsCheckResult::Fail;
+
+        return external_authenticators.checkTokenCredentials(
+            *token_credentials,
+            authentication_method.getTokenProcessorName(),
+            authentication_method.getJWTClaims()) ?
+            CredentialsCheckResult::Success : CredentialsCheckResult::Fail;
+    }
+
     if ([[maybe_unused]] const auto * always_allow_credentials = typeid_cast<const AlwaysAllowCredentials *>(&credentials))
         return CredentialsCheckResult::Success;
 
diff --git a/src/Access/AuthenticationData.cpp b/src/Access/AuthenticationData.cpp
index fdf3e0662f1d..5434cc8712df 100644
--- a/src/Access/AuthenticationData.cpp
+++ b/src/Access/AuthenticationData.cpp
@@ -28,13 +28,16 @@
 #     include <bcrypt.h>
 #endif
 
+#if USE_JWT_CPP
+#include <picojson/picojson.h>
+#endif
+
 namespace CurrentMetrics
 {
     extern const Metric BcryptCacheBytes;
     extern const Metric BcryptCacheSize;
 }
 
-
 namespace DB
 {
 
@@ -411,7 +414,10 @@ boost::intrusive_ptr<ASTAuthenticationData> AuthenticationData::toAST() const
         }
         case AuthenticationType::JWT:
         {
-            throw Exception(ErrorCodes::SUPPORT_IS_DISABLED, "JWT is available only in ClickHouse Cloud");
+            const auto & claims = getJWTClaims();
+            if (!claims.empty())
+                node->children.push_back(make_intrusive<ASTLiteral>(claims));
+            break;
         }
         case AuthenticationType::KERBEROS:
         {
@@ -689,6 +695,22 @@ AuthenticationData AuthenticationData::fromAST(const ASTAuthenticationData & que
         auth_data.setHTTPAuthenticationServerName(server);
         auth_data.setHTTPAuthenticationScheme(scheme);
     }
+#if USE_JWT_CPP
+    else if (query.type == AuthenticationType::JWT)
+    {
+        if (!args.empty())
+        {
+            String value = checkAndGetLiteralArgument<String>(args[0], "claims");
+            picojson::value json_obj;
+            auto error = picojson::parse(json_obj, value);
+            if (!error.empty())
+                throw Exception(ErrorCodes::BAD_ARGUMENTS, "Bad JWT claims: {}", error);
+            if (!json_obj.is<picojson::object>())
+                throw Exception(ErrorCodes::BAD_ARGUMENTS, "Bad JWT claims: is not an object");
+            auth_data.setJWTClaims(value);
+        }
+    }
+#endif
     else
     {
         throw Exception(ErrorCodes::LOGICAL_ERROR, "Unexpected ASTAuthenticationData structure");
diff --git a/src/Access/AuthenticationData.h b/src/Access/AuthenticationData.h
index 1eef3fad6187..f96fc68897a3 100644
--- a/src/Access/AuthenticationData.h
+++ b/src/Access/AuthenticationData.h
@@ -82,6 +82,12 @@ class AuthenticationData
     time_t getValidUntil() const { return valid_until; }
     void setValidUntil(time_t valid_until_) { valid_until = valid_until_; }
 
+    const String & getJWTClaims() const { return jwt_claims; }
+    void setJWTClaims(const String & jwt_claims_) { jwt_claims = jwt_claims_; }
+
+    const String & getTokenProcessorName() const { return token_processor_name; }
+    void setTokenProcessorName(const String & token_processor_name_) { token_processor_name = token_processor_name_; }
+
     friend bool operator ==(const AuthenticationData & lhs, const AuthenticationData & rhs);
     friend bool operator !=(const AuthenticationData & lhs, const AuthenticationData & rhs) { return !(lhs == rhs); }
 
@@ -121,6 +127,8 @@ class AuthenticationData
     String http_auth_server_name;
     HTTPAuthenticationScheme http_auth_scheme = HTTPAuthenticationScheme::BASIC;
     time_t valid_until = 0;
+    String jwt_claims;
+    String token_processor_name;
 };
 
 }
diff --git a/src/Access/Common/JWKSProvider.cpp b/src/Access/Common/JWKSProvider.cpp
new file mode 100644
index 000000000000..16c0dbba2423
--- /dev/null
+++ b/src/Access/Common/JWKSProvider.cpp
@@ -0,0 +1,113 @@
+#include <Access/Common/JWKSProvider.h>
+
+#if USE_JWT_CPP
+#include <Common/Exception.h>
+#include <mutex>
+#include <Poco/Net/HTTPRequest.h>
+#include <Poco/Net/HTTPResponse.h>
+#include <Poco/Net/HTTPSClientSession.h>
+#include <Poco/StreamCopier.h>
+#include <fstream>
+
+
+namespace DB
+{
+
+namespace ErrorCodes
+{
+    extern const int AUTHENTICATION_FAILED;
+    extern const int INVALID_CONFIG_PARAMETER;
+}
+
+JWKSType JWKSClient::getJWKS()
+{
+    {
+        std::shared_lock lock(mutex);
+        auto now = std::chrono::high_resolution_clock::now();
+        auto diff = std::chrono::duration<double>(now - last_request_send).count();
+        if (diff < static_cast<double>(refresh_timeout) && cached_jwks.has_value())
+            return cached_jwks.value();
+    }
+
+    std::unique_lock lock(mutex);
+    auto now = std::chrono::high_resolution_clock::now();
+    auto diff = std::chrono::duration<double>(now - last_request_send).count();
+    if (diff < static_cast<double>(refresh_timeout) && cached_jwks.has_value())
+        return cached_jwks.value();
+
+    Poco::Net::HTTPResponse response;
+    std::string response_string;
+
+    Poco::Net::HTTPRequest request{Poco::Net::HTTPRequest::HTTP_GET, jwks_uri.getPathAndQuery()};
+
+    if (jwks_uri.getScheme() == "https")
+    {
+        Poco::Net::HTTPSClientSession session = Poco::Net::HTTPSClientSession(jwks_uri.getHost(), jwks_uri.getPort());
+        session.sendRequest(request);
+        std::istream & response_stream = session.receiveResponse(response);
+        if (response.getStatus() != Poco::Net::HTTPResponse::HTTP_OK || !response_stream)
+            throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Failed to get user info by access token, code: {}, reason: {}",
+                response.getStatus(), response.getReason());
+        Poco::StreamCopier::copyToString(response_stream, response_string);
+    }
+    else
+    {
+        Poco::Net::HTTPClientSession session = Poco::Net::HTTPClientSession(jwks_uri.getHost(), jwks_uri.getPort());
+        session.sendRequest(request);
+        std::istream & response_stream = session.receiveResponse(response);
+        if (response.getStatus() != Poco::Net::HTTPResponse::HTTP_OK || !response_stream)
+            throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Failed to get user info by access token, code: {}, reason: {}", response.getStatus(), response.getReason());
+        Poco::StreamCopier::copyToString(response_stream, response_string);
+    }
+
+    last_request_send = std::chrono::high_resolution_clock::now();
+
+    JWKSType parsed_jwks;
+
+    try
+    {
+        parsed_jwks = jwt::parse_jwks(response_string);
+    }
+    catch (const std::exception & e)
+    {
+        throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Failed to parse JWKS: {}", e.what());
+    }
+
+    cached_jwks = std::move(parsed_jwks);
+    return cached_jwks.value();
+}
+
+StaticJWKSParams::StaticJWKSParams(const std::string & static_jwks_, const std::string & static_jwks_file_)
+{
+    if (static_jwks_.empty() && static_jwks_file_.empty())
+        throw Exception(ErrorCodes::INVALID_CONFIG_PARAMETER,
+                        "JWT validator misconfigured: `static_jwks` or `static_jwks_file` keys must be present in static JWKS validator configuration");
+    if (!static_jwks_.empty() && !static_jwks_file_.empty())
+        throw Exception(ErrorCodes::INVALID_CONFIG_PARAMETER,
+                        "JWT validator misconfigured: `static_jwks` and `static_jwks_file` keys cannot both be present in static JWKS validator configuration");
+
+    static_jwks = static_jwks_;
+    static_jwks_file = static_jwks_file_;
+}
+
+StaticJWKS::StaticJWKS(const StaticJWKSParams & params)
+{
+    String content = String(params.static_jwks);
+    if (!params.static_jwks_file.empty())
+    {
+        std::ifstream ifs(params.static_jwks_file);
+        Poco::StreamCopier::copyToString(ifs, content);
+    }
+    try
+    {
+        auto keys = jwt::parse_jwks(content);
+        jwks = std::move(keys);
+    }
+    catch (const std::exception & e)
+    {
+        throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Failed to parse JWKS: {}", e.what());
+    }
+}
+
+}
+#endif
diff --git a/src/Access/Common/JWKSProvider.h b/src/Access/Common/JWKSProvider.h
new file mode 100644
index 000000000000..566effd6e21e
--- /dev/null
+++ b/src/Access/Common/JWKSProvider.h
@@ -0,0 +1,73 @@
+#include <config.h>
+
+#if USE_JWT_CPP
+#include <base/types.h>
+#include <jwt-cpp/jwt.h>
+#include <jwt-cpp/traits/kazuho-picojson/traits.h>
+#include <shared_mutex>
+
+#include <Poco/URI.h>
+
+namespace DB
+{
+
+using JWKSType = jwt::jwks<jwt::traits::kazuho_picojson>;
+
+/// JWKS (JSON Web Key Set) is a kind of a set of public keys that are used to validate JWT authenticity locally.
+/// They are usually exposed by identity providers (e.g. Keycloak) via a well-known URI (usually /.well-known/jwks.json)
+/// This interface is responsible for managing JWKS. Retrieving, caching and refreshing of JWKS happens here.
+/// JWKS can either be static (e.g. provided in config) or dynamic (fetched from a remote URI and).
+class IJWKSProvider
+{
+public:
+    virtual ~IJWKSProvider() = default;
+
+    virtual JWKSType getJWKS() = 0;
+};
+
+class JWKSClient : public IJWKSProvider
+{
+public:
+    explicit JWKSClient(const String & uri, const size_t refresh_ms_): refresh_timeout(refresh_ms_), jwks_uri(uri) {}
+
+    ~JWKSClient() override = default;
+    JWKSClient(const JWKSClient &) = delete;
+    JWKSClient(JWKSClient &&) = delete;
+    JWKSClient &operator=(const JWKSClient &) = delete;
+    JWKSClient &operator=(JWKSClient &&) = delete;
+
+    JWKSType getJWKS() override;
+
+private:
+    size_t refresh_timeout;
+    Poco::URI jwks_uri;
+
+    std::shared_mutex mutex;
+    std::optional<JWKSType> cached_jwks;
+    std::chrono::time_point<std::chrono::high_resolution_clock> last_request_send;
+};
+
+struct StaticJWKSParams
+{
+    StaticJWKSParams(const std::string &static_jwks_, const std::string &static_jwks_file_);
+
+    String static_jwks;
+    String static_jwks_file;
+};
+
+class StaticJWKS : public IJWKSProvider
+{
+public:
+    explicit StaticJWKS(const StaticJWKSParams &params);
+
+private:
+    JWKSType getJWKS() override
+    {
+        return jwks;
+    }
+
+    JWKSType jwks;
+};
+
+}
+#endif
diff --git a/src/Access/Credentials.cpp b/src/Access/Credentials.cpp
index 4887d0545656..c60fb3cfea67 100644
--- a/src/Access/Credentials.cpp
+++ b/src/Access/Credentials.cpp
@@ -2,6 +2,7 @@
 #include <Common/Exception.h>
 #include <Poco/Net/HTTPRequest.h>
 #include <Common/Crypto/X509Certificate.h>
+#include <Common/logger_useful.h>
 
 namespace DB
 {
@@ -9,6 +10,7 @@ namespace DB
 namespace ErrorCodes
 {
     extern const int LOGICAL_ERROR;
+    extern const int AUTHENTICATION_FAILED;
 }
 
 Credentials::Credentials(const String & user_name_)
@@ -100,4 +102,7 @@ const String & BasicCredentials::getPassword() const
     return password;
 }
 
+/// Unless the token is validated, we will not use any data from it, including username.
+TokenCredentials::TokenCredentials(const String & token_) : Credentials(""), token(token_), expires_at(std::chrono::system_clock::now() + std::chrono::hours(1)) {}
+
 }
diff --git a/src/Access/Credentials.h b/src/Access/Credentials.h
index f98eb31ff0a2..bb81ef93a15c 100644
--- a/src/Access/Credentials.h
+++ b/src/Access/Credentials.h
@@ -16,6 +16,8 @@ namespace Poco::Net
     class SocketAddress;
 }
 
+#include <set>
+
 namespace DB
 {
 
@@ -195,4 +197,47 @@ class SSHPTYCredentials : public Credentials
 #endif
 
 
+class TokenCredentials : public Credentials
+{
+public:
+    explicit TokenCredentials(const String & token_);
+
+    const String & getToken() const
+    {
+        if (token.empty())
+        {
+            throwNotReady();
+        }
+        return token;
+    }
+    void setUserName(const String & user_name_)
+    {
+        user_name = user_name_;
+        if (!user_name.empty())
+        {
+            is_ready = true;
+        }
+    }
+    std::set<String> getGroups() const
+    {
+        return groups;
+    }
+    void setGroups(const std::set<String> & groups_)
+    {
+        groups = groups_;
+    }
+    std::optional<std::chrono::system_clock::time_point> getExpiresAt() const
+    {
+        return expires_at;
+    }
+    void setExpiresAt(std::chrono::system_clock::time_point expires_at_)
+    {
+        expires_at = expires_at_;
+    }
+private:
+    String token;
+    std::set<String> groups;
+    std::optional<std::chrono::system_clock::time_point> expires_at;
+};
+
 }
diff --git a/src/Access/ExternalAuthenticators.cpp b/src/Access/ExternalAuthenticators.cpp
index 6fa7c28bc980..a3516a0fcf3f 100644
--- a/src/Access/ExternalAuthenticators.cpp
+++ b/src/Access/ExternalAuthenticators.cpp
@@ -1,7 +1,10 @@
+#include <Access/Credentials.h>
 #include <Access/ExternalAuthenticators.h>
 #include <Access/LDAPClient.h>
 #include <Access/SettingsAuthResponseParser.h>
 #include <Access/resolveSetting.h>
+#include "Common/Logger.h"
+#include "Common/logger_useful.h"
 #include <Common/Exception.h>
 #include <Common/SettingsChanges.h>
 #include <Common/SipHash.h>
@@ -12,6 +15,8 @@
 #include <boost/algorithm/string/case_conv.hpp>
 #include <Poco/Util/AbstractConfiguration.h>
 
+#include <map>
+#include <memory>
 #include <optional>
 #include <utility>
 
@@ -267,7 +272,6 @@ HTTPAuthClientParams parseHTTPAuthParams(const Poco::Util::AbstractConfiguration
 
     return http_auth_params;
 }
-
 }
 
 void parseLDAPRoleSearchParams(LDAPClient::RoleSearchParams & params, const Poco::Util::AbstractConfiguration & config, const String & prefix)
@@ -285,6 +289,9 @@ void ExternalAuthenticators::resetImpl()
     ldap_client_params_blueprint.clear();
     ldap_caches.clear();
     kerberos_params.reset();
+    token_processors.clear();
+    access_token_to_username_cache.clear();
+    username_to_access_token_cache.clear();
 }
 
 void ExternalAuthenticators::reset()
@@ -293,10 +300,41 @@ void ExternalAuthenticators::reset()
     resetImpl();
 }
 
-void ExternalAuthenticators::setConfiguration(const Poco::Util::AbstractConfiguration & config, LoggerPtr log)
+void parseTokenProcessors(std::unordered_map<String, std::unique_ptr<ITokenProcessor>> & token_processors,
+                        const Poco::Util::AbstractConfiguration & config,
+                        const String & token_processors_config,
+                        LoggerPtr log)
+{
+    Poco::Util::AbstractConfiguration::Keys token_processors_keys;
+    config.keys(token_processors_config, token_processors_keys);
+
+    token_processors.clear();
+
+    for (const auto & processor : token_processors_keys)
+    {
+        String prefix = fmt::format("{}.{}", token_processors_config, processor);
+        try
+        {
+            token_processors[processor] = ITokenProcessor::parseTokenProcessor(config, prefix, processor);
+        }
+        catch (...)
+        {
+            tryLogCurrentException(log, "Could not parse token processor" + backQuote(processor));
+        }
+    }
+}
+
+bool ExternalAuthenticators::isTokenAuthEnabled() const
+{
+    std::lock_guard lock(mutex);
+    return token_auth_enabled;
+}
+
+void ExternalAuthenticators::setConfiguration(const Poco::Util::AbstractConfiguration & config, LoggerPtr log, bool token_auth_enabled_)
 {
     std::lock_guard lock(mutex);
     resetImpl();
+    token_auth_enabled = token_auth_enabled_;
 
     Poco::Util::AbstractConfiguration::Keys all_keys;
     config.keys("", all_keys);
@@ -304,8 +342,12 @@ void ExternalAuthenticators::setConfiguration(const Poco::Util::AbstractConfigur
     std::size_t ldap_servers_key_count = 0;
     std::size_t kerberos_keys_count = 0;
     std::size_t http_auth_server_keys_count = 0;
+    std::size_t jwt_validators_count = 0;
+    std::size_t token_processors_count = 0;
 
     const String http_auth_servers_config = "http_authentication_servers";
+    const String jwt_validators_config = "jwt_validators";
+    const String token_processors_config = "token_processors";
 
     for (auto key : all_keys)
     {
@@ -318,6 +360,8 @@ void ExternalAuthenticators::setConfiguration(const Poco::Util::AbstractConfigur
         ldap_servers_key_count += (key == "ldap_servers");
         kerberos_keys_count += (key == "kerberos");
         http_auth_server_keys_count += (key == http_auth_servers_config);
+        jwt_validators_count += (key == jwt_validators_config);
+        token_processors_count += (key == token_processors_config);
     }
 
     if (ldap_servers_key_count > 1)
@@ -329,6 +373,12 @@ void ExternalAuthenticators::setConfiguration(const Poco::Util::AbstractConfigur
     if (http_auth_server_keys_count > 1)
         throw Exception(ErrorCodes::BAD_ARGUMENTS, "Multiple http_authentication_servers sections are not allowed");
 
+    if (jwt_validators_count > 1)
+        throw Exception(ErrorCodes::BAD_ARGUMENTS, "Multiple {} sections are not allowed", jwt_validators_config);
+
+    if (token_processors_count > 1)
+        throw Exception(ErrorCodes::BAD_ARGUMENTS, "Multiple {} sections are not allowed", token_processors_config);
+
     Poco::Util::AbstractConfiguration::Keys http_auth_server_names;
     config.keys(http_auth_servers_config, http_auth_server_names);
     http_auth_servers.clear();
@@ -383,6 +433,11 @@ void ExternalAuthenticators::setConfiguration(const Poco::Util::AbstractConfigur
     {
         tryLogCurrentException(log, "Could not parse Kerberos section");
     }
+
+    if (token_auth_enabled)
+        parseTokenProcessors(token_processors, config, token_processors_config, log);
+    else
+        LOG_INFO(log, "Token authentication is disabled, skipping token processors configuration");
 }
 
 static UInt128 computeParamsHash(const LDAPClient::Params & params, const LDAPClient::RoleSearchParamsList * role_search_params)
@@ -551,7 +606,7 @@ GSSAcceptorContext::Params ExternalAuthenticators::getKerberosParams() const
     return kerberos_params.value();
 }
 
-HTTPAuthClientParams ExternalAuthenticators::getHTTPAuthenticationParams(const String& server) const
+HTTPAuthClientParams ExternalAuthenticators::getHTTPAuthenticationParams(const String & server) const
 {
     std::lock_guard lock{mutex};
 
@@ -561,6 +616,121 @@ HTTPAuthClientParams ExternalAuthenticators::getHTTPAuthenticationParams(const S
     return it->second;
 }
 
+bool ExternalAuthenticators::checkCredentialsAgainstProcessor(const ITokenProcessor & processor,
+                                                              TokenCredentials & credentials) const
+{
+    if (processor.resolveAndValidate(credentials))
+    {
+        TokenCacheEntry cache_entry;
+        cache_entry.user_name = credentials.getUserName();
+        cache_entry.external_roles = credentials.getGroups();
+
+        auto default_expiration_ts = std::chrono::system_clock::now()
+                                     + std::chrono::seconds(processor.getTokenCacheLifetime());
+
+        if (credentials.getExpiresAt().has_value())
+        {
+            if (credentials.getExpiresAt().value() < default_expiration_ts)
+                cache_entry.expires_at = credentials.getExpiresAt().value();
+            else
+            {
+                LOG_TRACE(getLogger("AccessTokenAuthentication"), "Token for user {} expires after default cache lifetime; using default TTL by {}", credentials.getUserName(), processor.getProcessorName());
+                cache_entry.expires_at = default_expiration_ts;
+            }
+        }
+        else
+        {
+            cache_entry.expires_at = default_expiration_ts;
+        }
+
+        LOG_DEBUG(getLogger("AccessTokenAuthentication"), "Authenticated user {} with access token by {}", credentials.getUserName(), processor.getProcessorName());
+
+        // CHeck if a cache entry for the same user but with another token exists -- old cache entry is considered outdated and removed
+        auto old_token_iter = username_to_access_token_cache.find(cache_entry.user_name);
+        if (old_token_iter != username_to_access_token_cache.end())
+        {
+            access_token_to_username_cache.erase(old_token_iter->second);
+            username_to_access_token_cache.erase(old_token_iter);
+        }
+
+        access_token_to_username_cache[credentials.getToken()] = cache_entry;
+        username_to_access_token_cache[cache_entry.user_name] = credentials.getToken();
+        LOG_TRACE(getLogger("AccessTokenAuthentication"), "Cache entry for user {} added", cache_entry.user_name);
+
+        return true;
+    }
+    LOG_TRACE(getLogger("AccessTokenAuthentication"), "Failed authentication with access token by {}", processor.getProcessorName());
+
+    return false;
+}
+
+bool ExternalAuthenticators::checkTokenCredentials(const TokenCredentials & credentials, const String & processor_name, const String & jwt_claims) const
+{
+    std::lock_guard lock{mutex};
+
+    if (!token_auth_enabled)
+        throw Exception(ErrorCodes::BAD_ARGUMENTS, "Token authentication is disabled");
+
+    if (token_processors.empty())
+        throw Exception(ErrorCodes::BAD_ARGUMENTS, "Token authentication is not configured");
+
+    /// Per-user claims restriction applies only to JWT processors; opaque/access token processors ignore it.
+    auto check_claims_if_required = [&](const ITokenProcessor & processor) -> bool
+    {
+        if (jwt_claims.empty())
+            return true;
+        if (!processor.supportsJwtClaimsRestriction())
+            return true;
+        return processor.checkClaims(credentials, jwt_claims);
+    };
+
+    /// lookup token in local cache if not expired.
+    auto cached_entry_iter = access_token_to_username_cache.find(credentials.getToken());
+    if (cached_entry_iter != access_token_to_username_cache.end())
+    {
+        if (cached_entry_iter->second.expires_at <= std::chrono::system_clock::now()) // Token found in cache, but already outdated -- need to remove it.
+        {
+            const auto expired_user_name = cached_entry_iter->second.user_name;
+            LOG_TRACE(getLogger("AccessTokenAuthentication"), "Cache entry for user {} expired, removing", expired_user_name);
+            access_token_to_username_cache.erase(cached_entry_iter);
+            username_to_access_token_cache.erase(expired_user_name);
+        }
+        else
+        {
+            const auto & user_data = cached_entry_iter->second;
+            const_cast<TokenCredentials &>(credentials).setUserName(user_data.user_name);
+            const_cast<TokenCredentials &>(credentials).setGroups(user_data.external_roles);
+            LOG_TRACE(getLogger("AccessTokenAuthentication"), "Cache entry for user {} found, using it to authenticate", cached_entry_iter->second.user_name);
+            if (!jwt_claims.empty())
+            {
+                if (processor_name.empty())
+                    return false;
+                const auto it = token_processors.find(processor_name);
+                if (it == token_processors.end() || !check_claims_if_required(*it->second))
+                    return false;
+            }
+            return true;
+        }
+    }
+
+    if (processor_name.empty())
+    {
+        for (const auto & it : token_processors)
+        {
+            if (checkCredentialsAgainstProcessor(*it.second, const_cast<TokenCredentials &>(credentials)))
+                return check_claims_if_required(*it.second);
+        }
+    }
+    else
+    {
+        const auto it = token_processors.find(processor_name);
+        if (it != token_processors.end() && checkCredentialsAgainstProcessor(*it->second, const_cast<TokenCredentials &>(credentials)))
+            return check_claims_if_required(*it->second);
+    }
+
+    return false;
+}
+
 bool ExternalAuthenticators::checkHTTPBasicCredentials(
     const String & server, const BasicCredentials & credentials, const ClientInfo & client_info, SettingsChanges & settings) const
 {
diff --git a/src/Access/ExternalAuthenticators.h b/src/Access/ExternalAuthenticators.h
index 6aa26bb3842a..1c539fa1917c 100644
--- a/src/Access/ExternalAuthenticators.h
+++ b/src/Access/ExternalAuthenticators.h
@@ -1,5 +1,6 @@
 #pragma once
 
+#include <Access/TokenProcessors.h>
 #include <Access/Credentials.h>
 #include <Access/GSSAcceptor.h>
 #include <Access/HTTPAuthClient.h>
@@ -13,6 +14,7 @@
 
 #include <chrono>
 #include <map>
+#include <memory>
 #include <mutex>
 #include <optional>
 #include <unordered_map>
@@ -37,7 +39,9 @@ class ExternalAuthenticators
 {
 public:
     void reset();
-    void setConfiguration(const Poco::Util::AbstractConfiguration & config, LoggerPtr log);
+    void setConfiguration(const Poco::Util::AbstractConfiguration & config, LoggerPtr log, bool token_auth_enabled = true);
+
+    bool isTokenAuthEnabled() const;
 
     // The name and readiness of the credentials must be verified before calling these.
     bool checkLDAPCredentials(const String & server, const BasicCredentials & credentials,
@@ -45,6 +49,8 @@ class ExternalAuthenticators
     bool checkKerberosCredentials(const String & realm, const GSSAcceptorContext & credentials) const;
     bool checkHTTPBasicCredentials(const String & server, const BasicCredentials & credentials, const ClientInfo & client_info, SettingsChanges & settings) const;
 
+    bool checkTokenCredentials(const TokenCredentials & credentials, const String & processor_name = "", const String & jwt_claims = "") const;
+
     GSSAcceptorContext::Params getKerberosParams() const;
 
 private:
@@ -66,6 +72,26 @@ class ExternalAuthenticators
     mutable LDAPCaches ldap_caches TSA_GUARDED_BY(mutex) ;
     std::optional<GSSAcceptorContext::Params> kerberos_params TSA_GUARDED_BY(mutex) ;
     std::unordered_map<String, HTTPAuthClientParams> http_auth_servers TSA_GUARDED_BY(mutex) ;
+    mutable std::unordered_map<String, std::unique_ptr<ITokenProcessor>> token_processors TSA_GUARDED_BY(mutex) ;
+
+    struct TokenCacheEntry
+    {
+        std::chrono::system_clock::time_point expires_at;
+        String user_name;
+        std::set<String> external_roles;
+    };
+
+    /// Home-made simple bi-mapping, needed to effectively clean up cache from old tokens.
+    using TokenToUsernameCache = std::unordered_map<String, TokenCacheEntry>;  // Access token -> cache entry
+    using UsernameToTokenCache = std::unordered_map<String, String>;                 // User name -> access token
+
+    mutable TokenToUsernameCache access_token_to_username_cache TSA_GUARDED_BY(mutex) ;
+    mutable UsernameToTokenCache username_to_access_token_cache TSA_GUARDED_BY(mutex) ;
+
+    bool token_auth_enabled TSA_GUARDED_BY(mutex) = true;
+
+    bool checkCredentialsAgainstProcessor(const ITokenProcessor & processor,
+                                          TokenCredentials & credentials) const TSA_REQUIRES(mutex);
 
     void resetImpl() TSA_REQUIRES(mutex);
 };
diff --git a/src/Access/IAccessStorage.cpp b/src/Access/IAccessStorage.cpp
index df9d8ceb205b..a776ff71036c 100644
--- a/src/Access/IAccessStorage.cpp
+++ b/src/Access/IAccessStorage.cpp
@@ -11,6 +11,7 @@
 #include <Common/Exception.h>
 #include <Common/quoteString.h>
 #include <Common/callOnce.h>
+#include "Access/Common/AuthenticationType.h"
 #include <IO/WriteHelpers.h>
 #include <Interpreters/Context.h>
 #include <Parsers/parseIdentifierOrStringLiteral.h>
@@ -34,6 +35,7 @@ namespace ErrorCodes
     extern const int ACCESS_STORAGE_READONLY;
     extern const int ACCESS_STORAGE_DOESNT_ALLOW_BACKUP;
     extern const int REQUIRED_SECOND_FACTOR;
+    extern const int AUTHENTICATION_FAILED;
     extern const int WRONG_PASSWORD;
     extern const int IP_ADDRESS_NOT_ALLOWED;
     extern const int LOGICAL_ERROR;
@@ -546,6 +548,9 @@ std::optional<AuthResult> IAccessStorage::authenticateImpl(
     bool allow_no_password,
     bool allow_plaintext_password) const
 {
+    if (typeid_cast<const TokenCredentials *>(&credentials) && !typeid_cast<const TokenCredentials *>(&credentials)->isReady())
+        throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Could not resolve username from token");
+
     if (auto id = find<User>(credentials.getUserName()))
     {
         if (auto user = tryRead<User>(*id))
diff --git a/src/Access/TokenAccessStorage.cpp b/src/Access/TokenAccessStorage.cpp
new file mode 100644
index 000000000000..fed2e86e1e0a
--- /dev/null
+++ b/src/Access/TokenAccessStorage.cpp
@@ -0,0 +1,591 @@
+#include <Access/TokenAccessStorage.h>
+#include <Access/AccessControl.h>
+#include <Access/ExternalAuthenticators.h>
+#include <Access/User.h>
+#include <Access/Role.h>
+#include <Access/SettingsProfile.h>
+#include <Access/Credentials.h>
+#include <Common/Exception.h>
+#include <Common/logger_useful.h>
+#include <Poco/JSON/JSON.h>
+#include <Poco/JSON/Object.h>
+#include <Poco/JSON/Stringifier.h>
+#include <boost/container_hash/hash.hpp>
+
+namespace DB
+{
+namespace ErrorCodes
+{
+    extern const int BAD_ARGUMENTS;
+}
+
+namespace
+{
+    struct ParsedTransform
+    {
+        String pattern;
+        String replacement;
+        bool global;
+    };
+
+    /// Unescape a string segment
+    String unescapeSegment(const String & str, size_t start, size_t end)
+    {
+        String result;
+        result.reserve(end - start);
+        bool escaped = false;
+
+        for (size_t i = start; i < end; ++i)
+        {
+            if (escaped)
+            {
+                result += str[i];
+                escaped = false;
+            }
+            else if (str[i] == '\\')
+                escaped = true;
+            else
+                result += str[i];
+        }
+
+        return result;
+    }
+
+    /// Parse sed-style transform pattern: s/pattern/replacement/flags
+    ParsedTransform parseSedTransform(const String & transform)
+    {
+        if (transform.size() < 4 || transform[0] != 's' || transform[1] != '/')
+        {
+            throw Exception(ErrorCodes::BAD_ARGUMENTS, "Invalid roles_transform format. Expected sed-style pattern like 's/pattern/replacement/g'");
+        }
+
+        bool escaped = false;
+        size_t first_slash = 1;
+        size_t second_slash = String::npos;
+        size_t third_slash = String::npos;
+
+        // Find delimiters using simple state machine
+        for (size_t i = first_slash + 1; i < transform.size(); ++i)
+        {
+            if (escaped)
+            {
+                escaped = false;
+                continue;
+            }
+
+            if (transform[i] == '\\')
+            {
+                escaped = true;
+                continue;
+            }
+
+            if (transform[i] == '/')
+            {
+                if (second_slash == String::npos)
+                    second_slash = i;
+                else if (third_slash == String::npos)
+                    third_slash = i;
+                else
+                    throw Exception(ErrorCodes::BAD_ARGUMENTS, "Invalid roles_transform format. Too many unescaped slashes. Expected sed-style pattern like 's/pattern/replacement/g'");
+            }
+        }
+
+        if (second_slash == String::npos || third_slash == String::npos)
+            throw Exception(ErrorCodes::BAD_ARGUMENTS, "Invalid roles_transform format. Expected sed-style pattern like 's/pattern/replacement/g'");
+
+        ParsedTransform result;
+
+        result.pattern = unescapeSegment(transform, first_slash + 1, second_slash);
+
+        size_t replacement_end = (third_slash != String::npos) ? third_slash : transform.size();
+        result.replacement = unescapeSegment(transform, second_slash + 1, replacement_end);
+
+        String flags = transform.substr(third_slash + 1);
+        result.global = (flags.find('g') != String::npos);
+
+        return result;
+    }
+
+    String applyTransform(const String & input, const String & pattern, const String & replacement, bool global)
+    {
+        if (pattern.empty())
+            return input;
+
+        re2::RE2 re(pattern);
+        if (!re.ok())
+            return input;
+
+        String result = input;
+        if (global)
+        {
+            RE2::GlobalReplace(&result, re, replacement);
+        }
+        else
+        {
+            RE2::Replace(&result, re, replacement);
+        }
+        return result;
+    }
+}
+
+TokenAccessStorage::TokenAccessStorage(const String & storage_name_, AccessControl & access_control_, const Poco::Util::AbstractConfiguration & config_, const String & prefix_)
+        : IAccessStorage(storage_name_), access_control(access_control_), config(config_), prefix(prefix_),
+        memory_storage(storage_name_, access_control.getChangesNotifier(), false)
+{
+    std::lock_guard lock(mutex);
+
+    const String prefix_str = (prefix.empty() ? "" : prefix + ".");
+
+    if (config.has(prefix_str + "roles_filter"))
+        roles_filter.emplace(config.getString(prefix_str + "roles_filter"));
+
+    if (config.has(prefix_str + "roles_transform"))
+    {
+        String transform = config.getString(prefix_str + "roles_transform");
+        ParsedTransform parsed = parseSedTransform(transform);
+        roles_transform_pattern = parsed.pattern;
+        roles_transform_replacement = parsed.replacement;
+        roles_transform_global = parsed.global;
+    }
+
+    provider_name = config.getString(prefix_str + "processor");
+    if (provider_name.empty())
+        throw Exception(ErrorCodes::BAD_ARGUMENTS, "'processor' must be specified for Token user directory");
+
+    std::set<String> common_roles_cfg;
+    if (config.has(prefix_str + "common_roles"))
+    {
+        Poco::Util::AbstractConfiguration::Keys role_names;
+        config.keys(prefix_str + "common_roles", role_names);
+
+        common_roles_cfg.insert(role_names.begin(), role_names.end());
+    }
+    common_role_names.swap(common_roles_cfg);
+
+    if (config.has(prefix_str + "default_profile"))
+        default_profile_name = config.getString(prefix_str + "default_profile");
+
+    user_external_roles.clear();
+    users_per_roles.clear();
+    roles_per_users.clear();
+    granted_role_names.clear();
+    granted_role_ids.clear();
+
+    role_change_subscription = access_control.subscribeForChanges<Role>(
+            [this] (const UUID & id, const AccessEntityPtr & entity)
+            {
+                this->processRoleChange(id, entity);
+            }
+    );
+}
+
+void TokenAccessStorage::applyRoleChangeNoLock(bool grant, const UUID & role_id, const String & role_name)
+{
+    std::vector<UUID> user_ids;
+
+    // Build a list of ids of the relevant users.
+    if (common_role_names.contains(role_name))
+    {
+        user_ids = memory_storage.findAll<User>();
+    }
+    else
+    {
+        const auto it = users_per_roles.find(role_name);
+        if (it != users_per_roles.end())
+        {
+            const auto & user_names = it->second;
+            user_ids.reserve(user_names.size());
+
+            for (const auto & user_name : user_names)
+            {
+                if (const auto user_id = memory_storage.find<User>(user_name))
+                    user_ids.emplace_back(*user_id);
+            }
+        }
+    }
+
+    // Update the granted roles of the relevant users.
+    if (!user_ids.empty())
+    {
+        auto update_func = [&role_id, &grant] (const AccessEntityPtr & entity_, const UUID &) -> AccessEntityPtr
+        {
+            if (auto user = typeid_cast<std::shared_ptr<const User>>(entity_))
+            {
+                auto changed_user = typeid_cast<std::shared_ptr<User>>(user->clone());
+                if (grant)
+                    changed_user->granted_roles.grant(role_id);
+                else
+                    changed_user->granted_roles.revoke(role_id);
+                return changed_user;
+            }
+            return entity_;
+        };
+
+        memory_storage.update(user_ids, update_func);
+    }
+
+    // Actualize granted_role_* mappings.
+    if (grant)
+    {
+        if (!user_ids.empty())
+        {
+            granted_role_names.insert_or_assign(role_id, role_name);
+            granted_role_ids.insert_or_assign(role_name, role_id);
+        }
+    }
+    else
+    {
+        granted_role_ids.erase(role_name);
+        granted_role_names.erase(role_id);
+    }
+}
+
+void TokenAccessStorage::processRoleChange(const UUID & id, const AccessEntityPtr & entity)
+{
+    std::lock_guard lock(mutex);
+    const auto role = typeid_cast<std::shared_ptr<const Role>>(entity);
+    const auto it = granted_role_names.find(id);
+
+    if (role) // Added or renamed a role.
+    {
+        const auto & new_role_name = role->getName();
+        if (it != granted_role_names.end()) // Renamed a granted role.
+        {
+            const auto & old_role_name = it->second;
+            if (new_role_name != old_role_name)
+            {
+                // Revoke the old role first, then grant the new role.
+                applyRoleChangeNoLock(false /* revoke */, id, old_role_name);
+                applyRoleChangeNoLock(true /* grant */, id, new_role_name);
+            }
+        }
+        else // Added a role.
+        {
+            applyRoleChangeNoLock(true /* grant */, id, new_role_name);
+        }
+    }
+    else // Removed a role.
+    {
+        if (it != granted_role_names.end()) // Removed a granted role.
+        {
+            const auto & old_role_name = it->second;
+            applyRoleChangeNoLock(false /* revoke */, id, old_role_name);
+        }
+    }
+}
+
+const char * TokenAccessStorage::getStorageType() const
+{
+    return STORAGE_TYPE;
+}
+
+bool TokenAccessStorage::exists(const UUID & id) const
+{
+    std::lock_guard lock(mutex);
+    return memory_storage.exists(id);
+}
+
+String TokenAccessStorage::getStorageParamsJSON() const
+{
+    std::lock_guard lock(mutex);
+    Poco::JSON::Object params_json;
+
+    params_json.set("provider", provider_name);
+
+    Poco::JSON::Array common_role_names_json;
+    for (const auto & role : common_role_names)
+    {
+        common_role_names_json.add(role);
+    }
+    params_json.set("roles", common_role_names_json);
+
+    std::ostringstream oss;     // STYLE_CHECK_ALLOW_STD_STRING_STREAM
+    oss.exceptions(std::ios::failbit);
+    Poco::JSON::Stringifier::stringify(params_json, oss);
+
+    return oss.str();
+}
+
+bool TokenAccessStorage::areTokenCredentialsValidNoLock(const User & user, const Credentials & credentials, const ExternalAuthenticators & external_authenticators) const
+{
+    if (!credentials.isReady())
+        return false;
+
+    if (credentials.getUserName() != user.getName())
+        return false;
+
+    if (const auto * token_credentials = dynamic_cast<const TokenCredentials *>(&credentials))
+        return external_authenticators.checkTokenCredentials(*token_credentials);
+
+    return false;
+}
+
+std::optional<UUID> TokenAccessStorage::findImpl(AccessEntityType type, const String & name) const
+{
+    std::lock_guard lock(mutex);
+    return memory_storage.find(type, name);
+}
+
+
+std::vector<UUID> TokenAccessStorage::findAllImpl(AccessEntityType type) const
+{
+    std::lock_guard lock(mutex);
+    return memory_storage.findAll(type);
+}
+
+AccessEntityPtr TokenAccessStorage::readImpl(const UUID & id, bool throw_if_not_exists) const
+{
+    std::lock_guard lock(mutex);
+    return memory_storage.read(id, throw_if_not_exists);
+}
+
+std::optional<std::pair<String, AccessEntityType>> TokenAccessStorage::readNameWithTypeImpl(const UUID & id, bool throw_if_not_exists) const
+{
+    std::lock_guard lock(mutex);
+    return memory_storage.readNameWithType(id, throw_if_not_exists);
+}
+
+void TokenAccessStorage::assignRolesNoLock(User & user, const std::set<String> & external_roles) const
+{
+    const auto & user_name = user.getName();
+    auto & granted_roles = user.granted_roles;
+
+    auto grant_role = [this, &user_name, &granted_roles] (const String & role_name, const bool common)
+    {
+        auto it = granted_role_ids.find(role_name);
+        if (it == granted_role_ids.end())
+        {
+            if (const auto role_id = access_control.find<Role>(role_name))
+            {
+                granted_role_names.insert_or_assign(*role_id, role_name);
+                it = granted_role_ids.insert_or_assign(role_name, *role_id).first;
+            }
+        }
+
+        if (it != granted_role_ids.end())
+        {
+            const auto & role_id = it->second;
+            granted_roles.grant(role_id);
+        }
+        else
+        {
+            LOG_TRACE(getLogger(), "Did not grant {} role '{}' to user '{}': role not found", (common ? "common" : "mapped"), role_name, user_name);
+        }
+    };
+
+    user_external_roles.erase(user_name);
+    granted_roles = {};
+    const auto old_role_names = std::move(roles_per_users[user_name]);
+
+    // Grant the common roles first.
+    for (const auto & role_name : common_role_names)
+    {
+        grant_role(role_name, true /* common */);
+    }
+
+    // Grant the mapped external roles and actualize users_per_roles mapping.
+    // external_roles allowed to overlap with common_role_names.
+    for (const auto & role_name : external_roles)
+    {
+        grant_role(role_name, false /* mapped */);
+        users_per_roles[role_name].insert(user_name);
+    }
+
+    // Cleanup users_per_roles and granted_role_* mappings.
+    for (const auto & old_role_name : old_role_names)
+    {
+        if (external_roles.contains(old_role_name))
+            continue;
+
+        const auto rit = users_per_roles.find(old_role_name);
+        if (rit == users_per_roles.end())
+            continue;
+
+        auto & user_names = rit->second;
+        user_names.erase(user_name);
+
+        if (!user_names.empty())
+            continue;
+
+        users_per_roles.erase(rit);
+
+        if (common_role_names.contains(old_role_name))
+            continue;
+
+        const auto iit = granted_role_ids.find(old_role_name);
+        if (iit == granted_role_ids.end())
+            continue;
+
+        const auto old_role_id = iit->second;
+        granted_role_names.erase(old_role_id);
+        granted_role_ids.erase(iit);
+    }
+
+    // Actualize roles_per_users mapping and user_external_roles cache.
+    if (external_roles.empty())
+        roles_per_users.erase(user_name);
+    else
+        roles_per_users[user_name] = external_roles;
+
+    user_external_roles[user_name] = external_roles;
+}
+
+void TokenAccessStorage::assignProfileNoLock(User & user) const
+{
+    if (default_profile_name.empty())
+        return;
+
+    const auto & user_name = user.getName();
+    auto & settings = user.settings;
+
+    // Look up the profile ID once
+    const auto profile_id = access_control.find<SettingsProfile>(default_profile_name);
+    if (!profile_id)
+    {
+        LOG_TRACE(getLogger(), "Did not assign profile '{}' to user '{}': profile not found", default_profile_name, user_name);
+        return;
+    }
+
+    // Check if profile is already assigned
+    bool profile_already_assigned = false;
+    for (const auto & element : settings)
+    {
+        if (element.parent_profile.has_value() && element.parent_profile == *profile_id)
+        {
+            profile_already_assigned = true;
+            break;
+        }
+    }
+
+    if (!profile_already_assigned)
+    {
+        SettingsProfileElement profile_element;
+        profile_element.parent_profile = *profile_id;
+        settings.push_back(std::move(profile_element));
+        LOG_TRACE(getLogger(), "Assigned profile '{}' to user '{}'", default_profile_name, user_name);
+    }
+}
+
+void TokenAccessStorage::updateAssignedRolesNoLock(const UUID & id, const String & user_name, const std::set<String> & external_roles) const
+{
+    // Map and grant the roles from scratch only if the list of external role has changed.
+    const auto it = user_external_roles.find(user_name);
+    if (it != user_external_roles.end() && it->second == external_roles)
+        return;
+
+    auto update_func = [this, &external_roles] (const AccessEntityPtr & entity_, const UUID &) -> AccessEntityPtr
+    {
+        if (auto user = typeid_cast<std::shared_ptr<const User>>(entity_))
+        {
+            auto changed_user = typeid_cast<std::shared_ptr<User>>(user->clone());
+            assignRolesNoLock(*changed_user, external_roles);
+            return changed_user;
+        }
+        return entity_;
+    };
+
+    memory_storage.update(id, update_func);
+}
+
+
+std::optional<AuthResult> TokenAccessStorage::authenticateImpl(
+        const Credentials & credentials,
+        const Poco::Net::IPAddress & address,
+        const ExternalAuthenticators & external_authenticators,
+        const ClientInfo & /* client_info */,
+        bool throw_if_user_not_exists,
+        bool /* allow_no_password */,
+        bool /* allow_plaintext_password */) const
+{
+    std::lock_guard lock(mutex);
+    auto id = memory_storage.find<User>(credentials.getUserName());
+    UserPtr user = id ? memory_storage.read<User>(*id) : nullptr;
+
+    const auto & token_credentials = typeid_cast<const TokenCredentials &>(credentials);
+
+    if (!external_authenticators.checkTokenCredentials(token_credentials, provider_name))
+    {
+        // Even though token itself may be valid (especially in case of a jwt token), authentication has just failed.
+        if (throw_if_user_not_exists)
+            throwNotFound(AccessEntityType::USER, credentials.getUserName(), getStorageName());
+
+        return {};
+    }
+
+    std::shared_ptr<User> new_user;
+    if (!user)
+    {
+        // User does not exist, so we create one, and will add it if authentication is successful.
+        new_user = std::make_shared<User>();
+        new_user->setName(credentials.getUserName());
+        new_user->authentication_methods.emplace_back(AuthenticationType::JWT);
+        user = new_user;
+    }
+
+    if (!isAddressAllowed(*user, address))
+        throwAddressNotAllowed(address);
+
+    std::set<String> external_roles;
+    if (roles_filter.has_value() && roles_filter.value().ok())
+    {
+        LOG_TRACE(getLogger(), "{}: External role filter found, applying only matching groups", getStorageName());
+        for (const auto & group: token_credentials.getGroups()) {
+            if (RE2::FullMatch(group, roles_filter.value()))
+            {
+                String transformed_group = group;
+                if (roles_transform_pattern.has_value() && roles_transform_replacement.has_value())
+                {
+                    transformed_group = applyTransform(group, roles_transform_pattern.value(), roles_transform_replacement.value(), roles_transform_global);
+                    LOG_TRACE(getLogger(), "{}: Transformed group '{}' to '{}'", getStorageName(), group, transformed_group);
+                }
+                external_roles.insert(transformed_group);
+                LOG_TRACE(getLogger(), "{}: Granted role (group) {} to user", getStorageName(), transformed_group);
+            }
+        }
+    }
+    else
+    {
+        LOG_TRACE(getLogger(), "{}: No external role filtering set, applying all available groups", getStorageName());
+        for (const auto & group: token_credentials.getGroups())
+        {
+            String transformed_group = group;
+            if (roles_transform_pattern.has_value() && roles_transform_replacement.has_value())
+            {
+                transformed_group = applyTransform(group, roles_transform_pattern.value(), roles_transform_replacement.value(), roles_transform_global);
+                LOG_TRACE(getLogger(), "{}: Transformed group '{}' to '{}'", getStorageName(), group, transformed_group);
+            }
+            external_roles.insert(transformed_group);
+        }
+    }
+
+    if (new_user)
+    {
+        assignRolesNoLock(*new_user, external_roles);
+        assignProfileNoLock(*new_user);
+        id = memory_storage.insert(new_user);
+    }
+    else
+    {
+        // Just in case external_roles are changed.
+        updateAssignedRolesNoLock(*id, user->getName(), external_roles);
+
+        // Also update profile if needed
+        memory_storage.update(*id, [this] (const AccessEntityPtr & entity_, const UUID &) -> AccessEntityPtr
+        {
+            if (auto user_entity = typeid_cast<std::shared_ptr<const User>>(entity_))
+            {
+                auto changed_user = typeid_cast<std::shared_ptr<User>>(user_entity->clone());
+                assignProfileNoLock(*changed_user);
+                return changed_user;
+            }
+            return entity_;
+        });
+    }
+
+    if (id)
+        return AuthResult{ .user_id = *id, .authentication_data = AuthenticationData(AuthenticationType::JWT), .user_name = user->getName() };
+    return std::nullopt;
+}
+
+
+}
diff --git a/src/Access/TokenAccessStorage.h b/src/Access/TokenAccessStorage.h
new file mode 100644
index 000000000000..aedf8843f2b9
--- /dev/null
+++ b/src/Access/TokenAccessStorage.h
@@ -0,0 +1,88 @@
+#pragma once
+
+#include <Access/MemoryAccessStorage.h>
+#include <Access/Credentials.h>
+#include <Access/SettingsProfile.h>
+#include <Common/re2.h>
+#include <base/types.h>
+#include <base/scope_guard.h>
+#include <map>
+#include <mutex>
+#include <set>
+#include <vector>
+
+
+namespace Poco
+{
+    namespace Util
+    {
+        class AbstractConfiguration;
+    }
+}
+
+
+namespace DB
+{
+class AccessControl;
+
+/// Implementation of IAccessStorage which allows to import user data from oauth server using access token.
+/// Normally, this should be unified with LDAPAccessStorage, but not done to minimize changes to code that is common with upstream.
+class TokenAccessStorage : public IAccessStorage
+{
+public:
+    static constexpr char STORAGE_TYPE[] = "token";
+
+    explicit TokenAccessStorage(const String & storage_name_, AccessControl & access_control_, const Poco::Util::AbstractConfiguration & config, const String & prefix);
+    ~TokenAccessStorage() override = default;
+
+    // IAccessStorage implementations.
+    const char * getStorageType() const override;
+    String getStorageParamsJSON() const override;
+    bool isReadOnly() const override { return true; }
+    bool exists(const UUID & id) const override;
+
+private:
+    mutable std::recursive_mutex mutex; // Note: Reentrance possible by internal role lookup via access_control
+    AccessControl & access_control;
+    const Poco::Util::AbstractConfiguration & config;
+    const String & prefix;
+
+    String provider_name;
+    std::optional<re2::RE2> roles_filter = std::nullopt;
+    std::optional<String> roles_transform_pattern = std::nullopt;
+    std::optional<String> roles_transform_replacement = std::nullopt;
+    bool roles_transform_global = false;
+
+    std::set<String> common_role_names;                         // role name that should be granted to all users at all times
+    String default_profile_name;                                // settings profile name that should be assigned to all users
+    mutable std::map<String, std::set<String>> user_external_roles;
+    mutable std::map<String, std::set<String>> users_per_roles; // role name -> user names (...it should be granted to; may but don't have to exist for common roles)
+    mutable std::map<String, std::set<String>> roles_per_users; // user name -> role names (...that should be granted to it; may but don't have to include common roles)
+    mutable std::map<UUID, String> granted_role_names;          // (currently granted) role id -> its name
+    mutable std::map<String, UUID> granted_role_ids;            // (currently granted) role name -> its id
+    mutable MemoryAccessStorage memory_storage;
+    scope_guard role_change_subscription;
+
+    void processRoleChange(const UUID & id, const AccessEntityPtr & entity);
+
+    bool areTokenCredentialsValidNoLock(const User & user, const Credentials & credentials, const ExternalAuthenticators & external_authenticators) const;
+
+    void applyRoleChangeNoLock(bool grant, const UUID & role_id, const String & role_name);
+    void assignRolesNoLock(User & user, const std::set<String> & external_roles) const;
+    void assignProfileNoLock(User & user) const;
+    void updateAssignedRolesNoLock(const UUID & id, const String & user_name, const std::set<String> & external_roles) const;
+
+protected:
+    std::optional<UUID> findImpl(AccessEntityType type, const String & name) const override;
+    std::vector<UUID> findAllImpl(AccessEntityType type) const override;
+    AccessEntityPtr readImpl(const UUID & id, bool throw_if_not_exists) const override;
+    std::optional<std::pair<String, AccessEntityType>> readNameWithTypeImpl(const UUID & id, bool throw_if_not_exists) const override;
+    std::optional<AuthResult> authenticateImpl(const Credentials & credentials,
+                                               const Poco::Net::IPAddress & address,
+                                               const ExternalAuthenticators & external_authenticators,
+                                               const ClientInfo & client_info,
+                                               bool throw_if_user_not_exists,
+                                               bool allow_no_password,
+                                               bool allow_plaintext_password) const override;
+};
+}
diff --git a/src/Access/TokenProcessors.h b/src/Access/TokenProcessors.h
new file mode 100644
index 000000000000..f33f0300662d
--- /dev/null
+++ b/src/Access/TokenProcessors.h
@@ -0,0 +1,230 @@
+#pragma once
+
+#include <Access/Credentials.h>
+#include <Poco/Util/AbstractConfiguration.h>
+
+#if USE_JWT_CPP
+#include <Access/Common/JWKSProvider.h>
+#include <jwt-cpp/jwt.h>
+#include <jwt-cpp/traits/kazuho-picojson/traits.h>
+#endif
+
+namespace DB
+{
+
+namespace ErrorCodes
+{
+extern const int NOT_IMPLEMENTED;
+}
+
+class ITokenProcessor
+{
+public:
+    explicit ITokenProcessor(const String & processor_name_,
+                             UInt64 token_cache_lifetime_,
+                             const String & username_claim_ = "sub",
+                             const String & groups_claim_ = "groups")
+    : processor_name(processor_name_), token_cache_lifetime(token_cache_lifetime_), username_claim(username_claim_), groups_claim(groups_claim_) {}
+    virtual ~ITokenProcessor() = default;
+
+    virtual bool resolveAndValidate(TokenCredentials &) const
+    {
+        throw Exception(ErrorCodes::NOT_IMPLEMENTED, "Not implemented for ITokenProcessor interface");
+    }
+
+    virtual bool checkClaims(const TokenCredentials &, const String &) const { return true; }
+
+    /// True only for JWT processors (static-key/JWKS). Opaque/access token processors do not use per-user claims.
+    virtual bool supportsJwtClaimsRestriction() const { return false; }
+
+    UInt64 getTokenCacheLifetime() const { return token_cache_lifetime; }
+    String getProcessorName() const { return processor_name; }
+
+    static std::unique_ptr<DB::ITokenProcessor> parseTokenProcessor(
+            const Poco::Util::AbstractConfiguration & config,
+            const String & prefix,
+            const String & processor_name);
+
+protected:
+    const String processor_name;
+    const UInt64 token_cache_lifetime;
+    const String username_claim;
+    const String groups_claim;
+};
+
+#if USE_JWT_CPP
+
+struct StaticKeyJwtParams
+{
+    /// Algorithm name (required). Supported: "none", "hs256", "hs384", "hs512",
+    /// "ps256", "ps384", "ps512", "ed25519", "ed448", "rs256", "rs384", "rs512",
+    /// "es256", "es256k", "es384", "es512"
+    String algo;
+
+    /// For HS algorithms (hs256, hs384, hs512): symmetric key (required for HS algorithms)
+    String static_key;
+
+    /// For HS algorithms: whether static_key is base64 encoded (optional, defaults to false)
+    bool static_key_in_base64 = false;
+
+    /// For PS/ED/RSA/ES algorithms: public key (required for PS/ED/RSA/ES algorithms)
+    String public_key;
+
+    /// For PS/ED/RSA/ES algorithms: private key (optional)
+    String private_key;
+
+    /// For PS/ED/RSA/ES algorithms: public key password (optional)
+    String public_key_password;
+
+    /// For PS/ED/RSA/ES algorithms: private key password (optional)
+    String private_key_password;
+
+    /// JWT claims to validate (optional)
+    String claims;
+};
+
+class StaticKeyJwtProcessor : public ITokenProcessor
+{
+public:
+    explicit StaticKeyJwtProcessor(const String & processor_name_,
+                                   UInt64 token_cache_lifetime_,
+                                   const String & username_claim_,
+                                   const String & groups_claim_,
+                                   const String & expected_issuer_,
+                                   const String & expected_audience_,
+                                   bool allow_no_expiration_,
+                                   const StaticKeyJwtParams & params);
+
+    bool resolveAndValidate(TokenCredentials & credentials) const override;
+    bool checkClaims(const TokenCredentials & credentials, const String & claims_to_check) const override;
+    bool supportsJwtClaimsRestriction() const override { return true; }
+
+private:
+    const String claims;
+    const String expected_issuer;
+    const String expected_audience;
+    const bool allow_no_expiration;
+    jwt::verifier<jwt::default_clock, jwt::traits::kazuho_picojson> verifier = jwt::verify();
+};
+
+
+class JwksJwtProcessor : public ITokenProcessor
+{
+public:
+    explicit JwksJwtProcessor(const String & processor_name_,
+                              UInt64 token_cache_lifetime_,
+                              const String & username_claim_,
+                              const String & groups_claim_,
+                              const String & expected_issuer_,
+                              const String & expected_audience_,
+                              bool allow_no_expiration_,
+                              const String & claims_,
+                              size_t verifier_leeway_,
+                              std::shared_ptr<IJWKSProvider> provider_)
+                              : ITokenProcessor(processor_name_, token_cache_lifetime_, username_claim_, groups_claim_),
+                                claims(claims_), expected_issuer(expected_issuer_), expected_audience(expected_audience_),
+                                allow_no_expiration(allow_no_expiration_), provider(provider_), verifier_leeway(verifier_leeway_) {}
+
+    explicit JwksJwtProcessor(const String & processor_name_,
+                              UInt64 token_cache_lifetime_,
+                              const String & username_claim_,
+                              const String & groups_claim_,
+                              const String & expected_issuer_,
+                              const String & expected_audience_,
+                              bool allow_no_expiration_,
+                              const String & claims_,
+                              size_t verifier_leeway_,
+                              const String & jwks_uri_,
+                              size_t jwks_cache_lifetime_)
+                              : JwksJwtProcessor(processor_name_,
+                                                 token_cache_lifetime_,
+                                                 username_claim_,
+                                                 groups_claim_,
+                                                 expected_issuer_,
+                                                 expected_audience_,
+                                                 allow_no_expiration_,
+                                                 claims_,
+                                                 verifier_leeway_,
+                                                 std::make_shared<JWKSClient>(jwks_uri_, jwks_cache_lifetime_)) {}
+
+    bool resolveAndValidate(TokenCredentials & credentials) const override;
+    bool checkClaims(const TokenCredentials & credentials, const String & claims_to_check) const override;
+    bool supportsJwtClaimsRestriction() const override { return true; }
+
+private:
+    const String claims;
+    const String expected_issuer;
+    const String expected_audience;
+    const bool allow_no_expiration;
+    mutable jwt::verifier<jwt::default_clock, jwt::traits::kazuho_picojson> verifier = jwt::verify();
+    std::shared_ptr<IJWKSProvider> provider;
+    const size_t verifier_leeway;
+};
+
+/// Opaque tokens
+
+class GoogleTokenProcessor : public ITokenProcessor
+{
+public:
+    GoogleTokenProcessor(const String & processor_name_,
+                         UInt64 token_cache_lifetime_,
+                         const String & username_claim_,
+                         const String & groups_claim_)
+            : ITokenProcessor(processor_name_, token_cache_lifetime_, username_claim_, groups_claim_) {}
+
+    bool resolveAndValidate(TokenCredentials & credentials) const override;
+};
+
+class AzureTokenProcessor : public ITokenProcessor
+{
+public:
+    AzureTokenProcessor(const String & processor_name_,
+                        UInt64 token_cache_lifetime_,
+                        const String & username_claim_,
+                        const String & groups_claim_)
+            : ITokenProcessor(processor_name_, token_cache_lifetime_, username_claim_, groups_claim_) {}
+
+    bool resolveAndValidate(TokenCredentials & credentials) const override;
+};
+
+class OpenIdTokenProcessor : public ITokenProcessor
+{
+public:
+    /// Specify endpoints manually
+    OpenIdTokenProcessor(const String & processor_name_,
+                         UInt64 token_cache_lifetime_,
+                         const String & username_claim_,
+                         const String & groups_claim_,
+                         const String & expected_issuer_,
+                         const String & expected_audience_,
+                         bool allow_no_expiration_,
+                         const String & userinfo_endpoint_,
+                         const String & token_introspection_endpoint_,
+                         UInt64 verifier_leeway_,
+                         const String & jwks_uri_,
+                         UInt64 jwks_cache_lifetime_);
+
+    /// Obtain endpoints from openid-configuration URL
+    OpenIdTokenProcessor(const String & processor_name_,
+                         UInt64 token_cache_lifetime_,
+                         const String & username_claim_,
+                         const String & groups_claim_,
+                         const String & expected_issuer_,
+                         const String & expected_audience_,
+                         bool allow_no_expiration_,
+                         const String & openid_config_endpoint_,
+                         UInt64 verifier_leeway_,
+                         UInt64 jwks_cache_lifetime_);
+
+    bool resolveAndValidate(TokenCredentials & credentials) const override;
+private:
+    Poco::URI userinfo_endpoint;
+    Poco::URI token_introspection_endpoint;
+
+    /// Access token is often a valid JWT, so we can validate it locally to avoid unnecesary network requests.
+    std::optional<JwksJwtProcessor> jwt_validator = std::nullopt;
+};
+
+#endif
+
+}
diff --git a/src/Access/TokenProcessorsJWT.cpp b/src/Access/TokenProcessorsJWT.cpp
new file mode 100644
index 000000000000..c666aca15c3a
--- /dev/null
+++ b/src/Access/TokenProcessorsJWT.cpp
@@ -0,0 +1,440 @@
+#include "TokenProcessors.h"
+
+#if USE_JWT_CPP
+#include <Common/Base64.h>
+#include <Common/logger_useful.h>
+#include <Poco/String.h>
+
+namespace DB {
+
+namespace ErrorCodes
+{
+    extern const int AUTHENTICATION_FAILED;
+    extern const int INVALID_CONFIG_PARAMETER;
+}
+
+namespace
+{
+
+bool check_claims(const picojson::value & claims, const picojson::value & payload, const String & path);
+bool check_claims(const picojson::value::object & claims, const picojson::value::object & payload, const String & path)
+{
+    for (const auto & it : claims)
+    {
+        const auto & payload_it = payload.find(it.first);
+        if (payload_it == payload.end())
+        {
+            LOG_TRACE(getLogger("TokenAuthentication"), "Key '{}.{}' not found in JWT payload", path, it.first);
+            return false;
+        }
+        if (!check_claims(it.second, payload_it->second, path + "." + it.first))
+        {
+            return false;
+        }
+    }
+    return true;
+}
+
+bool check_claims(const picojson::value::array & claims, const picojson::value::array & payload, const String & path)
+{
+    if (claims.size() > payload.size())
+    {
+        LOG_TRACE(getLogger("TokenAuthentication"), "JWT payload too small for claims key '{}'", path);
+        return false;
+    }
+    for (size_t claims_i = 0; claims_i < claims.size(); ++claims_i)
+    {
+        bool found = false;
+        const auto & claims_val = claims.at(claims_i);
+        for (const auto & payload_val : payload)
+        {
+            if (!check_claims(claims_val, payload_val, path + "[" + std::to_string(claims_i) + "]"))
+                continue;
+            found = true;
+        }
+        if (!found)
+        {
+            LOG_TRACE(getLogger("TokenAuthentication"), "JWT payload does not contain an object matching claims key '{}[{}]'", path, claims_i);
+            return false;
+        }
+    }
+    return true;
+}
+
+bool check_claims(const picojson::value & claims, const picojson::value & payload, const String & path)
+{
+    if (claims.is<picojson::array>())
+    {
+        if (!payload.is<picojson::array>())
+        {
+            LOG_TRACE(getLogger("TokenAuthentication"), "JWT payload does not match key type 'array' in claims '{}'", path);
+            return false;
+        }
+        return check_claims(claims.get<picojson::array>(), payload.get<picojson::array>(), path);
+    }
+    if (claims.is<picojson::object>())
+    {
+        if (!payload.is<picojson::object>())
+        {
+            LOG_TRACE(getLogger("TokenAuthentication"), "JWT payload does not match key type 'object' in claims '{}'", path);
+            return false;
+        }
+        return check_claims(claims.get<picojson::object>(), payload.get<picojson::object>(), path);
+    }
+    if (claims.is<bool>())
+    {
+        if (!payload.is<bool>())
+        {
+            LOG_TRACE(getLogger("TokenAuthentication"), "JWT payload does not match key type 'bool' in claims '{}'", path);
+            return false;
+        }
+        if (claims.get<bool>() != payload.get<bool>())
+        {
+            LOG_TRACE(getLogger("TokenAuthentication"), "JWT payload does not match the value in the '{}' assertions. Expected '{}' but given '{}'", path, claims.get<bool>(), payload.get<bool>());
+            return false;
+        }
+        return true;
+    }
+    if (claims.is<double>())
+    {
+        if (!payload.is<double>())
+        {
+            LOG_TRACE(getLogger("TokenAuthentication"), "JWT payload does not match key type 'double' in claims '{}'", path);
+            return false;
+        }
+        if (claims.get<double>() != payload.get<double>())
+        {
+            LOG_TRACE(getLogger("TokenAuthentication"), "JWT payload does not match the value in the '{}' assertions. Expected '{}' but given '{}'", path, claims.get<double>(), payload.get<double>());
+            return false;
+        }
+        return true;
+    }
+    if (claims.is<std::string>())
+    {
+        if (!payload.is<std::string>())
+        {
+            LOG_TRACE(getLogger("TokenAuthentication"), "JWT payload does not match key type 'std::string' in claims '{}'", path);
+            return false;
+        }
+        if (claims.get<std::string>() != payload.get<std::string>())
+        {
+            LOG_TRACE(getLogger("TokenAuthentication"), "JWT payload does not match the value in the '{}' assertions. Expected '{}' but given '{}'", path, claims.get<std::string>(), payload.get<std::string>());
+            return false;
+        }
+        return true;
+    }
+#ifdef PICOJSON_USE_INT64
+    if (claims.is<int64_t>())
+    {
+        if (!payload.is<int64_t>())
+        {
+            LOG_TRACE(getLogger("TokenAuthentication"), "JWT payload does not match key type 'int64_t' in claims '{}'", path);
+            return false;
+        }
+        if (claims.get<int64_t>() != payload.get<int64_t>())
+        {
+            LOG_TRACE(getLogger("TokenAuthentication"), "JWT payload does not match the value in claims '{}'. Expected '{}' but given '{}'", path, claims.get<int64_t>(), payload.get<int64_t>());
+            return false;
+        }
+        return true;
+    }
+#endif
+    LOG_ERROR(getLogger("TokenAuthentication"), "JWT claim '{}' does not match any known type", path);
+    return false;
+}
+
+bool check_claims(const String & claims, const picojson::value::object & payload)
+{
+    if (claims.empty())
+        return true;
+    picojson::value json;
+    auto errors = picojson::parse(json, claims);
+    if (!errors.empty())
+        throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Bad JWT claims: {}", errors);
+    if (!json.is<picojson::object>())
+        throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Bad JWT claims: is not an object");
+    return check_claims(json.get<picojson::value::object>(), payload, "");
+}
+
+}
+
+namespace
+{
+std::set<String> parseGroupsFromJsonArray(picojson::array groups_array)
+{
+    std::set<String> external_groups_names;
+
+    for (const auto & group : groups_array)
+    {
+        if (group.is<std::string>())
+            external_groups_names.insert(group.get<std::string>());
+    }
+
+    return external_groups_names;
+}
+}
+
+StaticKeyJwtProcessor::StaticKeyJwtProcessor(const String & processor_name_,
+                                             UInt64 token_cache_lifetime_,
+                                             const String & username_claim_,
+                                             const String & groups_claim_,
+                                             const String & expected_issuer_,
+                                             const String & expected_audience_,
+                                             bool allow_no_expiration_,
+                                             const StaticKeyJwtParams & params)
+                                             : ITokenProcessor(processor_name_, token_cache_lifetime_, username_claim_, groups_claim_),
+                                             claims(params.claims), expected_issuer(expected_issuer_), expected_audience(expected_audience_),
+                                             allow_no_expiration(allow_no_expiration_)
+{
+    const String & algo = params.algo;
+    const String & static_key = params.static_key;
+    bool static_key_in_base64 = params.static_key_in_base64;
+    const String & public_key = params.public_key;
+    const String & private_key = params.private_key;
+    const String & public_key_password = params.public_key_password;
+    const String & private_key_password = params.private_key_password;
+
+    if (algo == "ps256"   ||
+        algo == "ps384"   ||
+        algo == "ps512"   ||
+        algo == "ed25519" ||
+        algo == "ed448"   ||
+        algo == "rs256"   ||
+        algo == "rs384"   ||
+        algo == "rs512"   ||
+        algo == "es256"   ||
+        algo == "es256k"  ||
+        algo == "es384"   ||
+        algo == "es512"   )
+    {
+        if (public_key.empty())
+            throw Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "{}: Invalid token processor definition, `public_key` parameter required for {}", processor_name, algo);
+    }
+    else if (algo == "hs256" ||
+             algo == "hs384" ||
+             algo == "hs512" )
+    {
+        if (static_key.empty())
+            throw DB::Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "{}: Invalid token processor definition, `static_key` parameter required for {}", processor_name, algo);
+    }
+    else if (algo != "none")
+        throw DB::Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "{}: Invalid token processor definition, unknown algorithm {}", processor_name, algo);
+
+    if (algo == "none")
+        verifier = verifier.allow_algorithm(jwt::algorithm::none());
+    else if (algo == "ps256")
+        verifier = verifier.allow_algorithm(jwt::algorithm::ps256(public_key, private_key, public_key_password, private_key_password));
+    else if (algo == "ps384")
+        verifier = verifier.allow_algorithm(jwt::algorithm::ps384(public_key, private_key, public_key_password, private_key_password));
+    else if (algo == "ps512")
+        verifier = verifier.allow_algorithm(jwt::algorithm::ps512(public_key, private_key, public_key_password, private_key_password));
+    else if (algo == "ed25519")
+        verifier = verifier.allow_algorithm(jwt::algorithm::ed25519(public_key, private_key, public_key_password, private_key_password));
+    else if (algo == "ed448")
+        verifier = verifier.allow_algorithm(jwt::algorithm::ed448(public_key, private_key, public_key_password, private_key_password));
+    else if (algo == "rs256")
+        verifier = verifier.allow_algorithm(jwt::algorithm::rs256(public_key, private_key, public_key_password, private_key_password));
+    else if (algo == "rs384")
+        verifier = verifier.allow_algorithm(jwt::algorithm::rs384(public_key, private_key, public_key_password, private_key_password));
+    else if (algo == "rs512")
+        verifier = verifier.allow_algorithm(jwt::algorithm::rs512(public_key, private_key, public_key_password, private_key_password));
+    else if (algo == "es256")
+        verifier = verifier.allow_algorithm(jwt::algorithm::es256(public_key, private_key, public_key_password, private_key_password));
+    else if (algo == "es256k")
+        verifier = verifier.allow_algorithm(jwt::algorithm::es256k(public_key, private_key, public_key_password, private_key_password));
+    else if (algo == "es384")
+        verifier = verifier.allow_algorithm(jwt::algorithm::es384(public_key, private_key, public_key_password, private_key_password));
+    else if (algo == "es512")
+        verifier = verifier.allow_algorithm(jwt::algorithm::es512(public_key, private_key, public_key_password, private_key_password));
+    else if (algo.starts_with("hs"))
+    {
+        auto key = static_key;
+        if (static_key_in_base64)
+            key = base64Decode(key);
+        if (algo == "hs256")
+            verifier = verifier.allow_algorithm(jwt::algorithm::hs256(key));
+        else if (algo == "hs384")
+            verifier = verifier.allow_algorithm(jwt::algorithm::hs384(key));
+        else if (algo == "hs512")
+            verifier = verifier.allow_algorithm(jwt::algorithm::hs512(key));
+        else
+            throw Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "{}: Invalid token processor definition, unknown algorithm {}", processor_name, algo);
+    }
+    else
+        throw Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "{}: Invalid token processor definition, unknown algorithm {}", processor_name, algo);
+
+    if (!expected_issuer.empty())
+        verifier = verifier.with_issuer(expected_issuer);
+
+    if (!expected_audience.empty())
+        verifier = verifier.with_audience(expected_audience);
+}
+
+namespace
+{
+bool checkUserClaims(const TokenCredentials & credentials, const String & claims_to_check)
+{
+    try {
+        auto decoded_jwt = jwt::decode(credentials.getToken());
+        return check_claims(claims_to_check, decoded_jwt.get_payload_json());
+    }
+    catch (const std::exception &)
+    {
+        return false;
+    }
+}
+}
+
+bool StaticKeyJwtProcessor::checkClaims(const TokenCredentials & credentials, const String & claims_to_check) const
+{
+    return checkUserClaims(credentials, claims_to_check);
+}
+
+bool JwksJwtProcessor::checkClaims(const TokenCredentials & credentials, const String & claims_to_check) const
+{
+    return checkUserClaims(credentials, claims_to_check);
+}
+
+bool StaticKeyJwtProcessor::resolveAndValidate(TokenCredentials & credentials) const
+{
+    try
+    {
+        auto decoded_jwt = jwt::decode(credentials.getToken());
+        verifier.verify(decoded_jwt);
+
+        if (!allow_no_expiration && !decoded_jwt.has_expires_at())
+        {
+            LOG_TRACE(getLogger("TokenAuthentication"), "{}: Token missing 'exp' claim, rejecting", processor_name);
+            return false;
+        }
+
+        if (!check_claims(claims, decoded_jwt.get_payload_json()))
+            return false;
+
+        if (!decoded_jwt.has_payload_claim(username_claim))
+        {
+            LOG_ERROR(getLogger("TokenAuthentication"), "{}: Specified username_claim {} not found in token", processor_name, username_claim);
+            return false;
+        }
+
+        credentials.setUserName(decoded_jwt.get_payload_claim(username_claim).as_string());
+
+        if (decoded_jwt.has_payload_claim(groups_claim))
+            credentials.setGroups(parseGroupsFromJsonArray(decoded_jwt.get_payload_claim(groups_claim).as_array()));
+        else
+            LOG_TRACE(getLogger("TokenAuthentication"), "{}: Specified groups_claim {} not found in token, no external roles will be mapped", processor_name, groups_claim);
+
+        return true;
+    }
+    catch (const std::exception & ex)
+    {
+        LOG_TRACE(getLogger("TokenAuthentication"), "{}: Failed to validate JWT: {}", processor_name, ex.what());
+        return false;
+    }
+}
+
+bool JwksJwtProcessor::resolveAndValidate(TokenCredentials & credentials) const
+{
+    auto decoded_jwt = jwt::decode(credentials.getToken());
+
+    if (!allow_no_expiration && !decoded_jwt.has_expires_at())
+    {
+        LOG_TRACE(getLogger("TokenAuthentication"), "{}: Token missing 'exp' claim, rejecting", processor_name);
+        return false;
+    }
+
+    if (!decoded_jwt.has_payload_claim(username_claim))
+    {
+        LOG_ERROR(getLogger("TokenAuthentication"), "{}: Specified username_claim not found in token", processor_name);
+        return false;
+    }
+
+    if (!decoded_jwt.has_key_id())
+    {
+        LOG_ERROR(getLogger("TokenAuthentication"), "{}: 'kid' (key ID) claim not found in token", processor_name);
+        return false;
+    }
+
+    if (!provider->getJWKS().has_jwk(decoded_jwt.get_key_id()))
+        throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "JWKS error: no JWK found for JWT");
+
+    auto jwk = provider->getJWKS().get_jwk(decoded_jwt.get_key_id());
+    auto username = decoded_jwt.get_payload_claim(username_claim).as_string();
+
+    if (!decoded_jwt.has_algorithm())
+    {
+        LOG_ERROR(getLogger("TokenAuthentication"), "{}: Algorithm not specified in token", processor_name);
+        return false;
+    }
+    auto algo = Poco::toLower(decoded_jwt.get_algorithm());
+
+
+    String public_key;
+
+    try
+    {
+        auto x5c = jwk.get_x5c_key_value();
+
+        if (!x5c.empty())
+        {
+            LOG_TRACE(getLogger("TokenAuthentication"), "{}: Verifying {} with 'x5c' key", processor_name, username);
+            public_key = jwt::helper::convert_base64_der_to_pem(x5c);
+        }
+    }
+    catch (const jwt::error::claim_not_present_exception &)
+    {
+        LOG_TRACE(getLogger("TokenAuthentication"), "{}: x5c was not specified in JWK, will try RSA components", processor_name);
+    }
+    catch (const std::bad_cast &)
+    {
+        throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "JWT cannot be validated: invalid claim value type found, claims must be strings");
+    }
+
+    if (public_key.empty())
+    {
+        if (!(jwk.has_jwk_claim("n") && jwk.has_jwk_claim("e")))
+            throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "{}: invalid JWK: 'n' or 'e' not found", processor_name);
+        LOG_TRACE(getLogger("TokenAuthentication"), "{}: `issuer` or `x5c` not present, verifying {} with RSA components", processor_name, username);
+        const auto modulus = jwk.get_jwk_claim("n").as_string();
+        const auto exponent = jwk.get_jwk_claim("e").as_string();
+        public_key = jwt::helper::create_public_key_from_rsa_components(modulus, exponent);
+    }
+
+    if (jwk.has_algorithm() && Poco::toLower(jwk.get_algorithm()) != algo)
+        throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "JWT validation error: `alg` in JWK does not match the algorithm used in JWT");
+
+    if (algo == "rs256")
+        verifier = verifier.allow_algorithm(jwt::algorithm::rs256(public_key, "", "", ""));
+    else if (algo == "rs384")
+        verifier = verifier.allow_algorithm(jwt::algorithm::rs384(public_key, "", "", ""));
+    else if (algo == "rs512")
+        verifier = verifier.allow_algorithm(jwt::algorithm::rs512(public_key, "", "", ""));
+    else
+        throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "JWT cannot be validated: unknown algorithm {}", algo);
+
+    verifier = verifier.leeway(verifier_leeway);
+
+    if (!expected_issuer.empty())
+        verifier = verifier.with_issuer(expected_issuer);
+
+    if (!expected_audience.empty())
+        verifier = verifier.with_audience(expected_audience);
+
+    verifier.verify(decoded_jwt);
+
+    if (!claims.empty() && !check_claims(claims, decoded_jwt.get_payload_json()))
+        return false;
+
+    credentials.setUserName(decoded_jwt.get_payload_claim(username_claim).as_string());
+
+    if (decoded_jwt.has_payload_claim(groups_claim))
+        credentials.setGroups(parseGroupsFromJsonArray(decoded_jwt.get_payload_claim(groups_claim).as_array()));
+    else
+        LOG_TRACE(getLogger("TokenAuthentication"), "{}: Specified groups_claim {} not found in token, no external roles will be mapped", processor_name, groups_claim);
+
+    return true;
+}
+
+}
+
+#endif
diff --git a/src/Access/TokenProcessorsOpaque.cpp b/src/Access/TokenProcessorsOpaque.cpp
new file mode 100644
index 000000000000..b56113061fcb
--- /dev/null
+++ b/src/Access/TokenProcessorsOpaque.cpp
@@ -0,0 +1,402 @@
+#include "TokenProcessors.h"
+
+#if USE_JWT_CPP
+#include <Common/logger_useful.h>
+#include <Poco/StreamCopier.h>
+#include <Poco/Net/HTTPSClientSession.h>
+#include <Poco/Net/HTTPRequest.h>
+#include <Poco/Net/HTTPResponse.h>
+
+namespace DB {
+
+namespace ErrorCodes
+{
+    extern const int AUTHENTICATION_FAILED;
+    extern const int INVALID_CONFIG_PARAMETER;
+}
+
+namespace
+{
+    /// The JSON reply from provider has only a few key-value pairs, so no need for any advanced parsing.
+    /// Reduce complexity by using picojson.
+    picojson::object parseJSON(const String & json_string) {
+        picojson::value jsonValue;
+        std::string err = picojson::parse(jsonValue, json_string);
+
+        if (!err.empty()) {
+            throw std::runtime_error("JSON parsing error: " + err);
+        }
+
+        if (!jsonValue.is<picojson::object>()) {
+            throw std::runtime_error("JSON is not an object");
+        }
+
+        return jsonValue.get<picojson::object>();
+    }
+
+    template<typename ValueType = std::string, bool throw_on_exception = true>
+    std::optional<ValueType> getValueByKey(const picojson::object & jsonObject, const std::string & key) {
+        auto it = jsonObject.find(key); // Find the key in the object
+        if (it == jsonObject.end())
+        {
+            if constexpr (throw_on_exception)
+                throw std::runtime_error("Key not found: " + key);
+            else
+                return std::nullopt;
+        }
+
+        const picojson::value & value = it->second;
+        if (!value.is<ValueType>()) {
+            if constexpr (throw_on_exception)
+                throw std::runtime_error("Value for key '" + key + "' has incorrect type.");
+            else
+                return std::nullopt;
+        }
+
+        return value.get<ValueType>();
+    }
+
+    picojson::object getObjectFromURI(const Poco::URI & uri, const String & token = "")
+    {
+        Poco::Net::HTTPResponse response;
+        std::ostringstream responseString;
+
+        Poco::Net::HTTPRequest request{Poco::Net::HTTPRequest::HTTP_GET, uri.getPathAndQuery()};
+        if (!token.empty())
+            request.add("Authorization", "Bearer " + token);
+
+        if (uri.getScheme() == "https") {
+            Poco::Net::HTTPSClientSession session(uri.getHost(), uri.getPort());
+            session.sendRequest(request);
+            Poco::StreamCopier::copyStream(session.receiveResponse(response), responseString);
+        }
+        else
+        {
+            Poco::Net::HTTPClientSession session(uri.getHost(), uri.getPort());
+            session.sendRequest(request);
+            Poco::StreamCopier::copyStream(session.receiveResponse(response), responseString);
+        }
+
+        if (response.getStatus() != Poco::Net::HTTPResponse::HTTP_OK)
+            throw Exception(ErrorCodes::AUTHENTICATION_FAILED,
+                            "Failed to get user info by access token, code: {}, reason: {}", response.getStatus(),
+                            response.getReason());
+
+        try
+        {
+            return parseJSON(responseString.str());
+        }
+        catch (const std::runtime_error & e)
+        {
+            throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Failed to parse server response: {}", e.what());
+        }
+    }
+}
+
+bool GoogleTokenProcessor::resolveAndValidate(TokenCredentials & credentials) const
+{
+    const String & token = credentials.getToken();
+
+    std::unordered_map<String, String> user_info;
+    picojson::object user_info_json = getObjectFromURI(Poco::URI("https://www.googleapis.com/oauth2/v3/userinfo"), token);
+
+    if (!user_info_json.contains("email"))
+        throw Exception(ErrorCodes::AUTHENTICATION_FAILED,
+                        "{}: Specified username_claim {} not found in token", processor_name, username_claim);
+
+    user_info["email"] = getValueByKey<std::string, false>(user_info_json, "email").value_or("");
+
+    user_info[username_claim] = getValueByKey(user_info_json, username_claim).value();
+
+    String user_name = user_info[username_claim];
+
+    credentials.setUserName(user_name);
+
+    auto token_info = getObjectFromURI(Poco::URI("https://www.googleapis.com/oauth2/v3/tokeninfo"), token);
+    if (token_info.contains("exp"))
+        credentials.setExpiresAt(std::chrono::system_clock::from_time_t(static_cast<time_t>(getValueByKey<int64_t>(token_info, "exp").value())));
+
+    /// Groups info can only be retrieved if user email is known.
+    /// If no email found in user info, we skip this step and there are no external roles for the user.
+    if (!user_info["email"].empty())
+    {
+        std::set<String> external_groups_names;
+        const Poco::URI get_groups_uri = Poco::URI("https://cloudidentity.googleapis.com/v1/groups/-/memberships:searchDirectGroups?query=member_key_id==" + user_info["email"] + "'");
+
+        try
+        {
+            auto groups_response = getObjectFromURI(get_groups_uri, token);
+
+            if (!groups_response.contains("memberships") || !groups_response["memberships"].is<picojson::array>())
+            {
+                LOG_TRACE(getLogger("TokenAuthentication"),
+                          "{}: Failed to get Google groups: invalid content in response from server", processor_name);
+                return true;
+            }
+
+            for (const auto & group: groups_response["memberships"].get<picojson::array>())
+            {
+                if (!group.is<picojson::object>())
+                {
+                    LOG_TRACE(getLogger("TokenAuthentication"),
+                              "{}: Failed to get Google groups: invalid content in response from server", processor_name);
+                    continue;
+                }
+
+                auto group_data = group.get<picojson::object>();
+                String group_name = getValueByKey<std::string, false>(group_data["groupKey"].get<picojson::object>(), "id").value_or("");
+                if (!group_name.empty())
+                {
+                    external_groups_names.insert(group_name);
+                    LOG_TRACE(getLogger("TokenAuthentication"),
+                              "{}: User {}: new external group {}", processor_name, user_name, group_name);
+                }
+            }
+
+            credentials.setGroups(external_groups_names);
+        }
+        catch (const Exception & e)
+        {
+            /// Could not get groups info. Log it and skip it.
+            LOG_TRACE(getLogger("TokenAuthentication"),
+                      "{}: Failed to get Google groups, no external roles will be mapped. reason: {}", processor_name, e.what());
+            return true;
+        }
+    }
+
+    return true;
+}
+
+bool AzureTokenProcessor::resolveAndValidate(TokenCredentials & credentials) const
+{
+    /// Token is a JWT in this case, but we cannot directly verify it against Azure AD JWKS.
+    /// We will not trust user data in this token except for 'exp' value to determine caching duration.
+    /// Explanation here: https://stackoverflow.com/questions/60778634/failing-signature-validation-of-jwt-tokens-from-azure-ad
+    /// Let Azure validate it: only valid tokens will be accepted.
+    /// Use GET https://graph.microsoft.com/oidc/userinfo to verify token and get user info at the same time
+
+    const String & token = credentials.getToken();
+
+    try
+    {
+        picojson::object user_info_json = getObjectFromURI(Poco::URI("https://graph.microsoft.com/oidc/userinfo"), token);
+        String username = getValueByKey(user_info_json, username_claim).value();
+
+        if (!username.empty())
+            credentials.setUserName(username);
+        else
+            LOG_TRACE(getLogger("TokenAuthentication"), "{}: Failed to get username with token", processor_name);
+
+    }
+    catch (...)
+    {
+        return false;
+    }
+
+    try
+    {
+        credentials.setExpiresAt(jwt::decode(token).get_expires_at());
+    }
+    catch (...) {
+        LOG_TRACE(getLogger("TokenAuthentication"),
+                  "{}: No expiration data found in a valid token, will use default cache lifetime", processor_name);
+    }
+
+    std::set<String> external_groups_names;
+    const Poco::URI get_groups_uri = Poco::URI("https://graph.microsoft.com/v1.0/me/memberOf");
+
+    try
+    {
+        auto groups_response = getObjectFromURI(get_groups_uri, token);
+
+        if (!groups_response.contains("value") || !groups_response["value"].is<picojson::array>())
+        {
+            LOG_TRACE(getLogger("TokenAuthentication"),
+                      "{}: Failed to get Azure groups: invalid content in response from server", processor_name);
+            return true;
+        }
+
+        picojson::array groups_array = groups_response["value"].get<picojson::array>();
+
+        for (const auto & group: groups_array)
+        {
+            /// Got some invalid response. Ignore this, log this.
+            if (!group.is<picojson::object >())
+            {
+                LOG_TRACE(getLogger("TokenAuthentication"),
+                          "{}: Failed to get Azure groups: invalid content in response from server", processor_name);
+                continue;
+            }
+
+            auto group_data = group.get<picojson::object>();
+            if (!group_data.contains("displayName"))
+                continue;
+
+            String group_name = getValueByKey<std::string, false>(group_data, "displayName").value_or("");
+            if (!group_name.empty())
+            {
+                external_groups_names.insert(group_name);
+                LOG_TRACE(getLogger("TokenAuthentication"), "{}: User {}: new external group {}", processor_name, credentials.getUserName(), group_name);
+            }
+        }
+    }
+    catch (const Exception & e)
+    {
+        /// Could not get groups info. Log it and skip it.
+        LOG_TRACE(getLogger("TokenAuthentication"),
+                  "{}: Failed to get Azure groups, no external roles will be mapped. reason: {}", processor_name, e.what());
+        return true;
+    }
+
+    credentials.setGroups(external_groups_names);
+    return true;
+}
+
+OpenIdTokenProcessor::OpenIdTokenProcessor(const String & processor_name_,
+                                           UInt64 token_cache_lifetime_,
+                                           const String & username_claim_,
+                                           const String & groups_claim_,
+                                           const String & expected_issuer_,
+                                           const String & expected_audience_,
+                                           bool allow_no_expiration_,
+                                           const String & userinfo_endpoint_,
+                                           const String & token_introspection_endpoint_,
+                                           UInt64 verifier_leeway_,
+                                           const String & jwks_uri_,
+                                           UInt64 jwks_cache_lifetime_)
+        : ITokenProcessor(processor_name_, token_cache_lifetime_, username_claim_, groups_claim_),
+          userinfo_endpoint(userinfo_endpoint_), token_introspection_endpoint(token_introspection_endpoint_)
+{
+    if (!jwks_uri_.empty())
+    {
+        LOG_TRACE(getLogger("TokenAuthentication"), "{}: JWKS URI set, local JWT processing will be attempted", processor_name_);
+        jwt_validator.emplace(processor_name_ + "jwks_val",
+                              token_cache_lifetime_,
+                              username_claim_,
+                              groups_claim_,
+                              expected_issuer_,
+                              expected_audience_,
+                              allow_no_expiration_,
+                              "",
+                              verifier_leeway_,
+                              jwks_uri_,
+                              jwks_cache_lifetime_);
+    }
+}
+
+OpenIdTokenProcessor::OpenIdTokenProcessor(const String & processor_name_,
+                                           UInt64 token_cache_lifetime_,
+                                           const String & username_claim_,
+                                           const String & groups_claim_,
+                                           const String & expected_issuer_,
+                                           const String & expected_audience_,
+                                           bool allow_no_expiration_,
+                                           const String & openid_config_endpoint_,
+                                           UInt64 verifier_leeway_,
+                                           UInt64 jwks_cache_lifetime_)
+    : ITokenProcessor(processor_name_, token_cache_lifetime_, username_claim_, groups_claim_)
+{
+    const picojson::object openid_config = getObjectFromURI(Poco::URI(openid_config_endpoint_));
+
+    if (!openid_config.contains("userinfo_endpoint") || !openid_config.contains("introspection_endpoint"))
+        throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "{}: Cannot extract userinfo_endpoint or introspection_endpoint from OIDC configuration, consider manual configuration.", processor_name);
+
+    userinfo_endpoint = Poco::URI(getValueByKey(openid_config, "userinfo_endpoint").value());
+    token_introspection_endpoint = Poco::URI(getValueByKey(openid_config, "introspection_endpoint").value());
+
+    if (openid_config.contains("jwks_uri"))
+    {
+        LOG_TRACE(getLogger("TokenAuthentication"), "{}: JWKS URI set, local JWT processing will be attempted", processor_name_);
+        jwt_validator.emplace(processor_name_ + "jwks_val",
+                              token_cache_lifetime_,
+                              username_claim_,
+                              groups_claim_,
+                              expected_issuer_,
+                              expected_audience_,
+                              allow_no_expiration_,
+                              "",
+                              verifier_leeway_,
+                              getValueByKey(openid_config, "jwks_uri").value(),
+                              jwks_cache_lifetime_);
+    }
+}
+
+bool OpenIdTokenProcessor::resolveAndValidate(TokenCredentials & credentials) const
+{
+    const String & token = credentials.getToken();
+    String username;
+    picojson::object user_info_json;
+
+    if (jwt_validator.has_value() && jwt_validator.value().resolveAndValidate(credentials))
+    {
+        try
+        {
+            auto decoded_token = jwt::decode(token);
+            user_info_json = decoded_token.get_payload_json();
+            username = getValueByKey(user_info_json, username_claim).value();
+
+            /// TODO: Now we work only with Keycloak -- and it provides expires_at in token itself. Need to add actual token introspection logic for other OIDC providers.
+            if (decoded_token.has_expires_at())
+                credentials.setExpiresAt(decoded_token.get_expires_at());
+        }
+        catch (const std::exception & ex)
+        {
+            LOG_TRACE(getLogger("TokenAuthentication"), "{}: Failed to process token as JWT: {}", processor_name, ex.what());
+        }
+    }
+
+    /// If username or user info is empty -- local validation failed, trying introspection via provider
+    if (username.empty() || user_info_json.empty())
+    {
+        try
+        {
+            user_info_json = getObjectFromURI(userinfo_endpoint, token);
+            username = getValueByKey(user_info_json, username_claim).value();
+        }
+        catch (...)
+        {
+            return false;
+        }
+    }
+
+    if (user_info_json.empty())
+    {
+        LOG_TRACE(getLogger("TokenAuthentication"), "{}: Failed to obtain user info", processor_name);
+        return false;
+    }
+
+    if (username.empty())
+    {
+        LOG_TRACE(getLogger("TokenAuthentication"), "{}: Failed to get username", processor_name);
+        return false;
+    }
+
+    credentials.setUserName(username);
+
+    /// For now, list of groups is expected in a claim with specified name either in token itself or in userinfo response (Keycloak works this way)
+    /// TODO: add support for custom endpoints for retrieving groups. Keycloak lists groups in /userinfo and token itself, which is not always the case.
+    if (!groups_claim.empty() && user_info_json.contains(groups_claim))
+    {
+        if (!user_info_json[groups_claim].is<picojson::array>())
+        {
+            LOG_TRACE(getLogger("TokenAuthentication"),
+                      "{}: Failed to extract groups: invalid content in user data", processor_name);
+            return true;
+        }
+
+        std::set<String> external_groups_names;
+
+        picojson::array groups_array = user_info_json[groups_claim].get<picojson::array>();
+        for (const auto & group: groups_array)
+        {
+            if (group.is<std::string>())
+                external_groups_names.insert(group.get<std::string>());
+        }
+        credentials.setGroups(external_groups_names);
+    }
+
+    return true;
+}
+
+}
+#endif
diff --git a/src/Access/TokenProcessorsParse.cpp b/src/Access/TokenProcessorsParse.cpp
new file mode 100644
index 000000000000..fa83c5fa6a34
--- /dev/null
+++ b/src/Access/TokenProcessorsParse.cpp
@@ -0,0 +1,139 @@
+#include "TokenProcessors.h"
+
+#include <Common/logger_useful.h>
+#include <Poco/String.h>
+
+namespace DB {
+
+namespace ErrorCodes
+{
+    extern const int INVALID_CONFIG_PARAMETER;
+    extern const int SUPPORT_IS_DISABLED;
+}
+
+#if USE_JWT_CPP
+std::unique_ptr<DB::ITokenProcessor> ITokenProcessor::parseTokenProcessor(
+        const Poco::Util::AbstractConfiguration & config,
+        const String & prefix,
+        const String & processor_name)
+{
+    if (!config.hasProperty(prefix + ".type"))
+        throw DB::Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "'type' parameter shall be specified in token_processor configuration.'");
+
+    auto provider_type = Poco::toLower(config.getString(prefix + ".type"));
+
+    auto token_cache_lifetime = config.getUInt64(prefix + ".token_cache_lifetime", 3600);
+    auto username_claim = config.getString(prefix + ".username_claim", "sub");
+    auto groups_claim = config.getString(prefix + ".groups_claim", "groups");
+    auto expected_issuer = config.getString(prefix + ".expected_issuer", "");
+    auto expected_audience = config.getString(prefix + ".expected_audience", "");
+    auto allow_no_expiration = config.getBool(prefix + ".allow_no_expiration", false);
+
+    if (provider_type == "google")
+    {
+        return std::make_unique<GoogleTokenProcessor>(processor_name, token_cache_lifetime, username_claim, groups_claim);
+    }
+    else if (provider_type == "azure")
+    {
+        return std::make_unique<AzureTokenProcessor>(processor_name, token_cache_lifetime, username_claim, groups_claim);
+    }
+    else if (provider_type == "openid")
+    {
+        auto verifier_leeway = config.getUInt64(prefix + ".verifier_leeway", 60);
+        auto jwks_cache_lifetime = config.getUInt64(prefix + ".jwks_cache_lifetime", 3600);
+
+        bool externally_configured = config.hasProperty(prefix + ".configuration_endpoint") && !config.hasProperty(prefix + ".jwks_uri");
+        bool locally_configured = config.hasProperty(prefix + ".userinfo_endpoint") && config.hasProperty(prefix + ".token_introspection_endpoint");
+
+        if (externally_configured && ! locally_configured)
+        {
+            return std::make_unique<OpenIdTokenProcessor>(processor_name, token_cache_lifetime, username_claim, groups_claim,
+                                                          expected_issuer, expected_audience, allow_no_expiration,
+                                                          config.getString(prefix + ".configuration_endpoint"),
+                                                          verifier_leeway,
+                                                          jwks_cache_lifetime);
+        }
+        else if (locally_configured && !externally_configured)
+        {
+            return std::make_unique<OpenIdTokenProcessor>(processor_name, token_cache_lifetime, username_claim, groups_claim,
+                                                          expected_issuer, expected_audience, allow_no_expiration,
+                                                          config.getString(prefix + ".userinfo_endpoint"),
+                                                          config.getString(prefix + ".token_introspection_endpoint"),
+                                                          verifier_leeway,
+                                                          config.getString(prefix + ".jwks_uri", ""),
+                                                          jwks_cache_lifetime);
+        }
+
+        throw DB::Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "Either 'configuration_endpoint' or both 'userinfo_endpoint' and 'token_introspection_endpoint' (and, optionally, 'jwks_uri') must be specified for 'openid' processor");
+    }
+    else if (provider_type == "jwt_static_key")
+    {
+        if (!config.hasProperty(prefix + ".static_key"))
+            throw DB::Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "'static_key' must be specified for 'jwt_static_key' processor");
+
+        if (!config.hasProperty(prefix + ".algo"))
+            throw DB::Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "'algo' must be specified for 'jwt_static_key' processor");
+
+        StaticKeyJwtParams params = {Poco::toLower(config.getString(prefix + ".algo")),
+                                     config.getString(prefix + ".static_key", ""),
+                                     config.getBool(prefix + ".static_key_in_base64", false),
+                                     config.getString(prefix + ".public_key", ""),
+                                     config.getString(prefix + ".private_key", ""),
+                                     config.getString(prefix + ".public_key_password", ""),
+                                     config.getString(prefix + ".private_key_password", ""),
+                                     config.getString(prefix + ".claims", "")};
+        return std::make_unique<StaticKeyJwtProcessor>(processor_name, token_cache_lifetime, username_claim, groups_claim, expected_issuer, expected_audience, allow_no_expiration, params);
+    }
+    else if (provider_type == "jwt_static_jwks")
+    {
+        if (config.hasProperty(prefix + ".static_jwks") && config.hasProperty(prefix + ".static_jwks_file"))
+            throw DB::Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "'static_jwks' and 'static_jwks_file' cannot be specified simultaneously for 'jwt_static_jwks' processor");
+
+        if (!config.hasProperty(prefix + ".static_jwks") && !config.hasProperty(prefix + ".static_jwks_file"))
+            throw DB::Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "'static_jwks' or 'static_jwks_file' must be specified for 'jwt_static_jwks' processor");
+
+        if (config.hasProperty(prefix + ".jwks_uri"))
+            throw DB::Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "'jwks_uri' cannot be specified for 'jwt_static_jwks' processor");
+
+        StaticJWKSParams params
+        {
+            config.getString(prefix + ".static_jwks", ""),
+            config.getString(prefix + ".static_jwks_file", "")
+        };
+        return std::make_unique<JwksJwtProcessor>(processor_name, token_cache_lifetime, username_claim, groups_claim,
+                                                  expected_issuer, expected_audience, allow_no_expiration,
+                                                  config.getString(prefix + ".claims", ""),
+                                                  config.getUInt64(prefix + ".verifier_leeway", 0),
+                                                  std::make_shared<StaticJWKS>(params));
+    }
+    if (provider_type == "jwt_dynamic_jwks")
+    {
+        if (config.hasProperty(prefix + ".static_jwks") || config.hasProperty(prefix + ".static_jwks_file"))
+            throw DB::Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "'static_jwks' and 'static_jwks_file' cannot be specified for 'jwt_dynamic_jwks' processor");
+        if (!config.hasProperty(prefix + ".jwks_uri"))
+            throw DB::Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "'jwks_uri' must be specified for 'jwt_dynamic_jwks' processor");
+
+        return std::make_unique<JwksJwtProcessor>(processor_name, token_cache_lifetime, username_claim, groups_claim,
+                                                  expected_issuer, expected_audience, allow_no_expiration,
+                                                  config.getString(prefix + ".claims", ""),
+                                                  config.getUInt64(prefix + ".verifier_leeway", 0),
+                                                  config.getString(prefix + ".jwks_uri"),
+                                                  config.getUInt(prefix + ".jwks_cache_lifetime", 3600));
+    }
+    else
+        throw DB::Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "Invalid type: {}", provider_type);
+
+    // throw DB::Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "Failed to parse token processor: {}", processor_name);
+}
+
+#else
+std::unique_ptr<DB::ITokenProcessor> ITokenProcessor::parseTokenProcessor(
+    const Poco::Util::AbstractConfiguration &,
+    const String &,
+    const String &)
+{
+    throw DB::Exception(ErrorCodes::SUPPORT_IS_DISABLED, "Failed to parse token_processor, ClickHouse was built without JWT support.");
+}
+#endif
+
+}
diff --git a/src/Access/UsersConfigParser.cpp b/src/Access/UsersConfigParser.cpp
index 4d2eb83e9827..b7d840c32c64 100644
--- a/src/Access/UsersConfigParser.cpp
+++ b/src/Access/UsersConfigParser.cpp
@@ -132,6 +132,7 @@ namespace
         bool has_password_double_sha1_hex = config.has(user_config + ".password_double_sha1_hex");
         bool has_ldap = config.has(user_config + ".ldap");
         bool has_kerberos = config.has(user_config + ".kerberos");
+        bool has_jwt = config.has(user_config + ".jwt");
 
         const auto certificates_config = user_config + ".ssl_certificates";
         bool has_certificates = config.has(certificates_config);
@@ -143,18 +144,18 @@ namespace
         bool has_http_auth = config.has(http_auth_config);
 
         size_t num_password_fields = has_no_password + has_password_plaintext + has_password_sha256_hex + has_password_double_sha1_hex
-            + has_ldap + has_kerberos + has_certificates + has_ssh_keys + has_http_auth + has_scram_password_sha256_hex;
+            + has_ldap + has_kerberos + has_certificates + has_ssh_keys + has_http_auth + has_scram_password_sha256_hex + has_jwt;
 
         if (num_password_fields > 1)
             throw Exception(ErrorCodes::BAD_ARGUMENTS, "More than one field of 'password', 'password_sha256_hex', "
                             "'password_double_sha1_hex', 'no_password', 'ldap', 'kerberos', 'ssl_certificates', 'ssh_keys', "
-                            "'http_authentication' are used to specify authentication info for user {}. "
+                            "'http_authentication', 'jwt' are used to specify authentication info for user {}. "
                             "Must be only one of them.", user_name);
 
         if (num_password_fields < 1)
             throw Exception(ErrorCodes::BAD_ARGUMENTS, "Either 'password' or 'password_sha256_hex' "
                             "or 'password_double_sha1_hex' or 'no_password' or 'ldap' or 'kerberos "
-                            "or 'ssl_certificates' or 'ssh_keys' or 'http_authentication' must be specified for user {}.", user_name);
+                            "or 'ssl_certificates' or 'ssh_keys' or 'http_authentication' or 'jwt' must be specified for user {}.", user_name);
 
         std::optional<OneTimePasswordSecret> otp_secret;
         if (config.has(user_config + ".time_based_one_time_password"))
@@ -308,6 +309,15 @@ namespace
             auto scheme = config.getString(http_auth_config + ".scheme");
             user->authentication_methods.back().setHTTPAuthenticationScheme(parseHTTPAuthenticationScheme(scheme));
         }
+        else if (has_jwt)
+        {
+            user->authentication_methods.emplace_back(AuthenticationType::JWT);
+            const auto jwt_config = user_config + ".jwt";
+            if (config.has(jwt_config + ".processor"))
+                user->authentication_methods.back().setTokenProcessorName(config.getString(jwt_config + ".processor"));
+            if (config.has(jwt_config + ".claims"))
+                user->authentication_methods.back().setJWTClaims(config.getString(jwt_config + ".claims"));
+        }
         else
         {
             user->authentication_methods.emplace_back();
diff --git a/src/Core/ServerSettings.cpp b/src/Core/ServerSettings.cpp
index cbaaab35929c..09520a9768d5 100644
--- a/src/Core/ServerSettings.cpp
+++ b/src/Core/ServerSettings.cpp
@@ -825,6 +825,15 @@ namespace
     A value of `0` means unlimited.
     :::
     )", 0) \
+    DECLARE(Bool, enable_token_auth, true, R"(
+    Controls whether token-based (JWT) authentication is enabled.
+    When disabled:
+    - Token processors from the `token_processors` section are not parsed.
+    - `TokenAccessStorage` (token user directory) is not added.
+    - Authentication via tokens (`--jwt` option in clickhouse-client or `Authorization: Bearer` HTTP header) is rejected.
+
+    Default value: `true` (token authentication is enabled).
+    )", 0) \
     DECLARE(UInt64, concurrent_threads_soft_limit_num, 0, R"(
     The maximum number of query processing threads, excluding threads for retrieving data from remote servers, allowed to run all queries. This is not a hard limit. In case if the limit is reached the query will still get at least one thread to run. Query can upscale to desired number of threads during execution if more threads become available.
 
diff --git a/src/Parsers/Access/ASTAuthenticationData.cpp b/src/Parsers/Access/ASTAuthenticationData.cpp
index 35e7d2032d83..7fe8de9bdb5b 100644
--- a/src/Parsers/Access/ASTAuthenticationData.cpp
+++ b/src/Parsers/Access/ASTAuthenticationData.cpp
@@ -116,8 +116,11 @@ void ASTAuthenticationData::formatImpl(WriteBuffer & ostr, const FormatSettings
             }
             case AuthenticationType::JWT:
             {
-                prefix = "CLAIMS";
-                parameter = true;
+                if (!children.empty())
+                {
+                    prefix = "CLAIMS";
+                    parameter = true;
+                }
                 break;
             }
             case AuthenticationType::LDAP:
diff --git a/src/Parsers/Access/ASTCreateUserQuery.h b/src/Parsers/Access/ASTCreateUserQuery.h
index 3bf6d76f484f..92e3a8b250fd 100644
--- a/src/Parsers/Access/ASTCreateUserQuery.h
+++ b/src/Parsers/Access/ASTCreateUserQuery.h
@@ -17,7 +17,7 @@ class ASTAuthenticationData;
 
 
 /** CREATE USER [IF NOT EXISTS | OR REPLACE] name
-  *     [NOT IDENTIFIED | IDENTIFIED {[WITH {no_password|plaintext_password|sha256_password|sha256_hash|double_sha1_password|double_sha1_hash}] BY {'password'|'hash'}}|{WITH ldap SERVER 'server_name'}|{WITH kerberos [REALM 'realm']}]
+  *     [NOT IDENTIFIED | IDENTIFIED {[WITH {no_password|plaintext_password|sha256_password|sha256_hash|double_sha1_password|double_sha1_hash}] BY {'password'|'hash'}}|{WITH ldap SERVER 'server_name'}|{WITH kerberos [REALM 'realm']|{WITH jwt [CLAIMS 'json_object']}}]
   *     [HOST {LOCAL | NAME 'name' | REGEXP 'name_regexp' | IP 'address' | LIKE 'pattern'} [,...] | ANY | NONE]
   *     [DEFAULT ROLE role [,...]]
   *     [DEFAULT DATABASE database | NONE]
@@ -26,7 +26,7 @@ class ASTAuthenticationData;
   *
   * ALTER USER [IF EXISTS] name
   *     [RENAME TO new_name]
-  *     [NOT IDENTIFIED | IDENTIFIED {[WITH {no_password|plaintext_password|sha256_password|sha256_hash|double_sha1_password|double_sha1_hash}] BY {'password'|'hash'}}|{WITH ldap SERVER 'server_name'}|{WITH kerberos [REALM 'realm']}]
+  *     [NOT IDENTIFIED | IDENTIFIED {[WITH {no_password|plaintext_password|sha256_password|sha256_hash|double_sha1_password|double_sha1_hash}] BY {'password'|'hash'}}|{WITH ldap SERVER 'server_name'}|{WITH kerberos [REALM 'realm']|{WITH jwt [CLAIMS 'json_object']}}]
   *     [[ADD|DROP] HOST {LOCAL | NAME 'name' | REGEXP 'name_regexp' | IP 'address' | LIKE 'pattern'} [,...] | ANY | NONE]
   *     [DEFAULT ROLE role [,...] | ALL | ALL EXCEPT role [,...] ]
   *     [DEFAULT DATABASE database | NONE]
diff --git a/src/Parsers/Access/ParserCreateUserQuery.cpp b/src/Parsers/Access/ParserCreateUserQuery.cpp
index cb1561c0c912..5e520151e4f6 100644
--- a/src/Parsers/Access/ParserCreateUserQuery.cpp
+++ b/src/Parsers/Access/ParserCreateUserQuery.cpp
@@ -83,6 +83,7 @@ namespace
             bool expect_ssl_cert_subjects = false;
             bool expect_public_ssh_key = false;
             bool expect_http_auth_server = false;
+            bool expect_claims = false;   // NOLINT
 
             auto parse_non_password_based_type = [&](auto check_type)
             {
@@ -105,6 +106,7 @@ namespace
                         expect_http_auth_server = true;
                     else if (check_type == AuthenticationType::JWT)
                         throw Exception(ErrorCodes::BAD_ARGUMENTS, "CREATE USER is not supported for JWT");
+                        // expect_claims = true;
                     else if (check_type != AuthenticationType::NO_PASSWORD)
                         expect_password = true;
 
@@ -165,6 +167,7 @@ namespace
             ASTPtr http_auth_scheme;
             ASTPtr ssl_cert_subjects;
             std::optional<String> ssl_cert_subject_type;
+            ASTPtr jwt_claims;
 
             if (expect_password || expect_hash)
             {
@@ -229,6 +232,14 @@ namespace
                         return false;
                 }
             }
+            else if (expect_claims)
+            {
+                if (ParserKeyword{Keyword::CLAIMS}.ignore(pos, expected))
+                {
+                    if (!ParserStringAndSubstitution{}.parse(pos, jwt_claims, expected))
+                        return false;
+                }
+            }
 
             auth_data = make_intrusive<ASTAuthenticationData>();
 
@@ -254,6 +265,9 @@ namespace
             if (http_auth_scheme)
                 auth_data->children.push_back(std::move(http_auth_scheme));
 
+            if (jwt_claims)
+                auth_data->children.push_back(std::move(jwt_claims));
+
             parseValidUntil(pos, expected, auth_data->valid_until);
 
             return true;
diff --git a/src/Parsers/Access/ParserCreateUserQuery.h b/src/Parsers/Access/ParserCreateUserQuery.h
index 4dfff8713d76..5f4cfcd6c45f 100644
--- a/src/Parsers/Access/ParserCreateUserQuery.h
+++ b/src/Parsers/Access/ParserCreateUserQuery.h
@@ -7,7 +7,7 @@ namespace DB
 {
 /** Parses queries like
   * CREATE USER [IF NOT EXISTS | OR REPLACE] name
-  *     [NOT IDENTIFIED | IDENTIFIED {[WITH {no_password|plaintext_password|sha256_password|sha256_hash|double_sha1_password|double_sha1_hash}] BY {'password'|'hash'}}|{WITH ldap SERVER 'server_name'}|{WITH kerberos [REALM 'realm']}]
+  *     [NOT IDENTIFIED | IDENTIFIED {[WITH {no_password|plaintext_password|sha256_password|sha256_hash|double_sha1_password|double_sha1_hash}] BY {'password'|'hash'}}|{WITH ldap SERVER 'server_name'}|{WITH kerberos [REALM 'realm']}|{WITH jwt}]
   *     [HOST {LOCAL | NAME 'name' | REGEXP 'name_regexp' | IP 'address' | LIKE 'pattern'} [,...] | ANY | NONE]
   *     [DEFAULT ROLE role [,...]]
   *     [SETTINGS variable [= value] [MIN [=] min_value] [MAX [=] max_value] [CONST|READONLY|WRITABLE|CHANGEABLE_IN_READONLY] | PROFILE 'profile_name'] [,...]
@@ -15,7 +15,7 @@ namespace DB
   *
   * ALTER USER [IF EXISTS] name
   *     [RENAME TO new_name]
-  *     [NOT IDENTIFIED | IDENTIFIED {[WITH {no_password|plaintext_password|sha256_password|sha256_hash|double_sha1_password|double_sha1_hash}] BY {'password'|'hash'}}|{WITH ldap SERVER 'server_name'}|{WITH kerberos [REALM 'realm']}]
+  *     [NOT IDENTIFIED | IDENTIFIED {[WITH {no_password|plaintext_password|sha256_password|sha256_hash|double_sha1_password|double_sha1_hash}] BY {'password'|'hash'}}|{WITH ldap SERVER 'server_name'}|{WITH kerberos [REALM 'realm']}|{WITH jwt}]
   *     [[ADD|DROP] HOST {LOCAL | NAME 'name' | REGEXP 'name_regexp' | IP 'address' | LIKE 'pattern'} [,...] | ANY | NONE]
   *     [DEFAULT ROLE role [,...] | ALL | ALL EXCEPT role [,...] ]
   *     [ADD|MODIFY SETTINGS variable [=value] [MIN [=] min_value] [MAX [=] max_value] [CONST|READONLY|WRITABLE|CHANGEABLE_IN_READONLY] [,...] ]
diff --git a/src/Parsers/CommonParsers.h b/src/Parsers/CommonParsers.h
index 1ee268b20895..21f51ddbfab7 100644
--- a/src/Parsers/CommonParsers.h
+++ b/src/Parsers/CommonParsers.h
@@ -94,6 +94,7 @@ namespace DB
     MR_MACROS(CHECK_TABLE, "CHECK TABLE") \
     MR_MACROS(CHECK_GRANT, "CHECK GRANT") \
     MR_MACROS(CHECK, "CHECK") \
+    MR_MACROS(CLAIMS, "CLAIMS") \
     MR_MACROS(CLEANUP, "CLEANUP") \
     MR_MACROS(CLEAR_COLUMN, "CLEAR COLUMN") \
     MR_MACROS(CLEAR_INDEX, "CLEAR INDEX") \
diff --git a/src/Server/HTTP/authenticateUserByHTTP.cpp b/src/Server/HTTP/authenticateUserByHTTP.cpp
index d2399a662b67..670f9c60694a 100644
--- a/src/Server/HTTP/authenticateUserByHTTP.cpp
+++ b/src/Server/HTTP/authenticateUserByHTTP.cpp
@@ -1,3 +1,4 @@
+#include <Access/AccessControl.h>
 #include <Access/Authentication.h>
 #include <Access/Credentials.h>
 #include <Access/ExternalAuthenticators.h>
@@ -16,7 +17,6 @@
 #    include <Common/Crypto/X509Certificate.h>
 #endif
 
-
 namespace DB
 {
 
@@ -86,6 +86,8 @@ bool authenticateUserByHTTP(
     bool has_http_credentials = request.hasCredentials() && request.get("Authorization") != "never";
     bool has_credentials_in_query_params = params.has("user") || params.has("password");
 
+    String bearer_token;
+
     std::string spnego_challenge;
 #if USE_SSL
     X509Certificate::Subjects certificate_subjects;
@@ -164,6 +166,12 @@ bool authenticateUserByHTTP(
             if (spnego_challenge.empty())
                 throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Invalid authentication: SPNEGO challenge is empty");
         }
+        else if (Poco::icompare(scheme, "Bearer") == 0)
+        {
+            bearer_token = auth_info;
+            if (bearer_token.empty())
+                throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Invalid authentication: Bearer token is empty");
+        }
         else
         {
             throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Invalid authentication: '{}' HTTP Authorization scheme is not supported", scheme);
@@ -221,6 +229,20 @@ bool authenticateUserByHTTP(
         }
     }
 #endif
+    else if (!bearer_token.empty())
+    {
+        const auto & access_control = global_context->getAccessControl();
+        if (!access_control.isTokenAuthEnabled())
+            throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Token authentication is disabled");
+
+        const auto token_credentials = TokenCredentials(bearer_token);
+        const auto & external_authenticators = access_control.getExternalAuthenticators();
+
+        if (!external_authenticators.checkTokenCredentials(token_credentials))
+            throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Invalid authentication: Token could not be verified.");
+
+        current_credentials = std::make_unique<TokenCredentials>(token_credentials);
+    }
     else // I.e., now using user name and password strings ("Basic").
     {
         if (!current_credentials)
diff --git a/src/Server/TCPHandler.cpp b/src/Server/TCPHandler.cpp
index 85ad3a977a4c..1a2fc88eef65 100644
--- a/src/Server/TCPHandler.cpp
+++ b/src/Server/TCPHandler.cpp
@@ -40,6 +40,7 @@
 #include <base/scope_guard.h>
 #include <Poco/Net/SocketAddress.h>
 #include <Poco/Util/LayeredConfiguration.h>
+#include <Access/ExternalAuthenticators.h>
 #include <Common/OpenTelemetryTraceContext.h>
 #include <Common/CurrentMetrics.h>
 #include <Common/CurrentThread.h>
@@ -1925,6 +1926,10 @@ void TCPHandler::receiveHello()
     if (is_ssh_based_auth)
         user.erase(0, std::string_view(EncodedUserInfo::SSH_KEY_AUTHENTICAION_MARKER).size());
 
+    is_jwt_based_auth = user.starts_with(EncodedUserInfo::JWT_AUTHENTICAION_MARKER);
+    if (is_jwt_based_auth)
+        user.erase(0, std::string_view(EncodedUserInfo::JWT_AUTHENTICAION_MARKER).size());
+
     session = makeSession();
     const auto & client_info = session->getClientInfo();
 
@@ -2012,6 +2017,23 @@ void TCPHandler::receiveHello()
     }
 #endif
 
+    if (is_jwt_based_auth)
+    {
+        const auto & access_control = server.context()->getAccessControl();
+        if (!access_control.isTokenAuthEnabled())
+            throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Token authentication is disabled");
+
+        auto credentials = TokenCredentials(password);
+
+        const auto & external_authenticators = access_control.getExternalAuthenticators();
+
+        if (!external_authenticators.checkTokenCredentials(credentials))
+            throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Token is invalid");
+
+        session->authenticate(credentials, getClientAddress(client_info), socket().peerAddress());
+        return;
+    }
+
     session->authenticate(user, password, getClientAddress(client_info), socket().peerAddress());
 }
 
diff --git a/src/Server/TCPHandler.h b/src/Server/TCPHandler.h
index 3ed235d5d998..1e78790b4f5a 100644
--- a/src/Server/TCPHandler.h
+++ b/src/Server/TCPHandler.h
@@ -238,6 +238,7 @@ class TCPHandler : public Poco::Net::TCPServerConnection
     String default_database;
 
     bool is_ssh_based_auth = false; /// authentication is via SSH pub-key challenge
+    bool is_jwt_based_auth = false; /// authentication is via JWT
     /// For inter-server secret (remote_server.*.secret)
     bool is_interserver_mode = false;
     bool is_interserver_authenticated = false;
diff --git a/src/Storages/System/StorageSystemBuildOptions.cpp.in b/src/Storages/System/StorageSystemBuildOptions.cpp.in
index b6580c3ede27..e2c41bac8fa5 100644
--- a/src/Storages/System/StorageSystemBuildOptions.cpp.in
+++ b/src/Storages/System/StorageSystemBuildOptions.cpp.in
@@ -75,6 +75,7 @@ const char * auto_config_build[]
     "GIT_BRANCH", R"IRjaNsZIL9Yh7FQ4(@GIT_BRANCH@)IRjaNsZIL9Yh7FQ4",
     "GIT_DATE", "@GIT_DATE@",
     "GIT_COMMIT_SUBJECT", R"Gi17KJMlbGCjErEN(@GIT_COMMIT_SUBJECT@)Gi17KJMlbGCjErEN",
+    "USE_JWT_CPP", "@USE_JWT_CPP@",
 
     nullptr, nullptr
 };
diff --git a/tests/integration/test_jwt_auth/__init__.py b/tests/integration/test_jwt_auth/__init__.py
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/tests/integration/test_jwt_auth/configs/users.xml b/tests/integration/test_jwt_auth/configs/users.xml
new file mode 100644
index 000000000000..b3d3372ebaa9
--- /dev/null
+++ b/tests/integration/test_jwt_auth/configs/users.xml
@@ -0,0 +1,15 @@
+<clickhouse>
+    <profiles>
+        <default>
+        </default>
+    </profiles>
+    <users>
+        <jwt_user>
+            <jwt>
+<!--                <claims>{"resource_access": "view-profile"}</claims>-->
+            </jwt>
+            <profile>default</profile>
+            <quota>default</quota>
+        </jwt_user>
+    </users>
+</clickhouse>
diff --git a/tests/integration/test_jwt_auth/configs/validators.xml b/tests/integration/test_jwt_auth/configs/validators.xml
new file mode 100644
index 000000000000..e6bb8a1d265e
--- /dev/null
+++ b/tests/integration/test_jwt_auth/configs/validators.xml
@@ -0,0 +1,26 @@
+<clickhouse>
+    <token_processors>
+
+        <single_key_processor>
+            <type>jwt_static_key</type>
+            <algo>HS256</algo>
+            <static_key>my_secret</static_key>
+            <static_key_in_base64>false</static_key_in_base64>
+            <allow_no_expiration>true</allow_no_expiration>
+        </single_key_processor>
+
+        <another_single_key_processor>
+            <type>jwt_static_key</type>
+            <algo>hs256</algo>
+            <static_key>other_secret</static_key>
+            <static_key_in_base64>false</static_key_in_base64>
+            <allow_no_expiration>true</allow_no_expiration>
+        </another_single_key_processor>
+
+        <remote_jwks_processor>
+            <type>jwt_dynamic_jwks</type>
+            <jwks_uri>http://resolver:8080/.well-known/jwks.json</jwks_uri>
+            <allow_no_expiration>true</allow_no_expiration>
+        </remote_jwks_processor>
+    </token_processors>
+</clickhouse>
diff --git a/tests/integration/test_jwt_auth/helpers/generate_private_key.py b/tests/integration/test_jwt_auth/helpers/generate_private_key.py
new file mode 100644
index 000000000000..7b54fa63368b
--- /dev/null
+++ b/tests/integration/test_jwt_auth/helpers/generate_private_key.py
@@ -0,0 +1,21 @@
+from cryptography.hazmat.primitives.asymmetric import rsa
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.backends import default_backend
+
+# Generate RSA private key
+private_key = rsa.generate_private_key(
+    public_exponent=65537,
+    key_size=2048,  # Key size of 2048 bits
+    backend=default_backend()
+)
+
+# Save the private key to a PEM file
+pem_private_key = private_key.private_bytes(
+    encoding=serialization.Encoding.PEM,
+    format=serialization.PrivateFormat.TraditionalOpenSSL,
+    encryption_algorithm=serialization.NoEncryption()  # You can add encryption if needed
+)
+
+# Write the private key to a file
+with open("new_private_key", "wb") as pem_file:
+    pem_file.write(pem_private_key)
diff --git a/tests/integration/test_jwt_auth/helpers/jwt_jwk.py b/tests/integration/test_jwt_auth/helpers/jwt_jwk.py
new file mode 100644
index 000000000000..265882efce76
--- /dev/null
+++ b/tests/integration/test_jwt_auth/helpers/jwt_jwk.py
@@ -0,0 +1,113 @@
+from cryptography.hazmat.primitives.asymmetric import rsa
+from cryptography.hazmat.primitives import serialization
+
+import base64
+import json
+import jwt
+
+
+"""
+Only RS* family algorithms are supported!!!
+"""
+with open("./private_key_2", "rb") as key_file:
+    private_key = serialization.load_pem_private_key(
+        key_file.read(),
+        password=None,
+    )
+
+
+public_key = private_key.public_key()
+
+
+def to_base64_url(data):
+    return base64.urlsafe_b64encode(data).decode("utf-8").rstrip("=")
+
+
+def rsa_key_to_jwk(private_key=None, public_key=None):
+    if private_key:
+        # Convert the private key to its components
+        private_numbers = private_key.private_numbers()
+        public_numbers = private_key.public_key().public_numbers()
+
+        jwk = {
+            "kty": "RSA",
+            "alg": "RS512",
+            "kid": "mykid",
+            "n": to_base64_url(
+                public_numbers.n.to_bytes(
+                    (public_numbers.n.bit_length() + 7) // 8, byteorder="big"
+                )
+            ),
+            "e": to_base64_url(
+                public_numbers.e.to_bytes(
+                    (public_numbers.e.bit_length() + 7) // 8, byteorder="big"
+                )
+            ),
+            "d": to_base64_url(
+                private_numbers.d.to_bytes(
+                    (private_numbers.d.bit_length() + 7) // 8, byteorder="big"
+                )
+            ),
+            "p": to_base64_url(
+                private_numbers.p.to_bytes(
+                    (private_numbers.p.bit_length() + 7) // 8, byteorder="big"
+                )
+            ),
+            "q": to_base64_url(
+                private_numbers.q.to_bytes(
+                    (private_numbers.q.bit_length() + 7) // 8, byteorder="big"
+                )
+            ),
+            "dp": to_base64_url(
+                private_numbers.dmp1.to_bytes(
+                    (private_numbers.dmp1.bit_length() + 7) // 8, byteorder="big"
+                )
+            ),
+            "dq": to_base64_url(
+                private_numbers.dmq1.to_bytes(
+                    (private_numbers.dmq1.bit_length() + 7) // 8, byteorder="big"
+                )
+            ),
+            "qi": to_base64_url(
+                private_numbers.iqmp.to_bytes(
+                    (private_numbers.iqmp.bit_length() + 7) // 8, byteorder="big"
+                )
+            ),
+        }
+    elif public_key:
+        # Convert the public key to its components
+        public_numbers = public_key.public_numbers()
+
+        jwk = {
+            "kty": "RSA",
+            "alg": "RS512",
+            "kid": "mykid",
+            "n": to_base64_url(
+                public_numbers.n.to_bytes(
+                    (public_numbers.n.bit_length() + 7) // 8, byteorder="big"
+                )
+            ),
+            "e": to_base64_url(
+                public_numbers.e.to_bytes(
+                    (public_numbers.e.bit_length() + 7) // 8, byteorder="big"
+                )
+            ),
+        }
+    else:
+        raise ValueError("You must provide either a private or public key.")
+
+    return jwk
+
+
+# Convert to JWK
+jwk_private = rsa_key_to_jwk(private_key=private_key)
+jwk_public = rsa_key_to_jwk(public_key=public_key)
+
+print(f"Private JWK:\n{json.dumps(jwk_private)}\n")
+print(f"Public JWK:\n{json.dumps(jwk_public)}\n")
+
+payload = {"sub": "jwt_user", "iss": "test_iss"}
+
+# Create a JWT
+token = jwt.encode(payload, private_key, headers={"kid": "mykid"}, algorithm="RS512")
+print(f"JWT:\n{token}")
diff --git a/tests/integration/test_jwt_auth/helpers/jwt_static_secret.py b/tests/integration/test_jwt_auth/helpers/jwt_static_secret.py
new file mode 100644
index 000000000000..5f1c7e0340af
--- /dev/null
+++ b/tests/integration/test_jwt_auth/helpers/jwt_static_secret.py
@@ -0,0 +1,43 @@
+import jwt
+import datetime
+
+
+def create_jwt(
+    payload: dict, secret: str, algorithm: str = "HS256", expiration_minutes: int = None
+) -> str:
+    """
+    Create a JWT using a static secret and a specified encryption algorithm.
+
+    :param payload: The payload to include in the JWT (as a dictionary).
+    :param secret: The secret key used to sign the JWT.
+    :param algorithm: The encryption algorithm to use (default is 'HS256').
+    :param expiration_minutes: The time until the token expires (default is 60 minutes).
+    :return: The encoded JWT as a string.
+    """
+    if expiration_minutes:
+        expiration = datetime.datetime.utcnow() + datetime.timedelta(
+            minutes=expiration_minutes
+        )
+        payload["exp"] = expiration
+
+    return jwt.encode(payload, secret, algorithm=algorithm)
+
+
+if __name__ == "__main__":
+    secret = "my_secret"
+    payload = {"sub": "jwt_user"}  # `sub` must contain user name
+
+    """
+    Supported algorithms:
+      | HMSC  | RSA   | ECDSA  | PSS   | EdDSA   |
+      | ----- | ----- | ------ | ----- | ------- |
+      | HS256 | RS256 | ES256  | PS256 | Ed25519 |
+      | HS384 | RS384 | ES384  | PS384 | Ed448   |
+      | HS512 | RS512 | ES512  | PS512 |         |
+      |       |       | ES256K |       |         |
+      And None
+    """
+    algorithm = "HS256"
+
+    token = create_jwt(payload, secret, algorithm)
+    print(f"Generated JWT: {token}")
diff --git a/tests/integration/test_jwt_auth/helpers/private_key_1 b/tests/integration/test_jwt_auth/helpers/private_key_1
new file mode 100644
index 000000000000..a076a86e17a4
--- /dev/null
+++ b/tests/integration/test_jwt_auth/helpers/private_key_1
@@ -0,0 +1,27 @@
+-----BEGIN RSA PRIVATE KEY-----
+MIIEowIBAAKCAQEAlICGC8S5pObyASih5qfmwuclG0oKsbzY2z9vgwqyhTYQOWcq
+YcTjVV4aQ30qb6E0+5W6rJ+jx9zx6GuAEGMiG/aWJEdbUAMGp+L1kz4lrw5U6Glw
+oZIvk4wqoRwsiyc+mnDMQAmiZLBNyt3wU6YnKgYmb4O1cSzcZ5HMbImJpj4tpYjq
+nIazvYMn/9Pxjkl0ezLCr52av0UkWHro1H4QMVfuEoNmHuWPww9jgHn+I+La0xdO
+hRpAa0XnJi65dXZd4330uWjeJwt413yz881uS4n1OLOGKG8ImDcNlwU/guyvk0n0
+aqT0zkOAPp9/yYo13MPWmiRCfOX8ozdN7VDIJwIDAQABAoIBADZfiLUuZrrWRK3f
+7sfBmmCquY9wYNILT2uXooDcndjgnrgl6gK6UHKlbgBgB/WvlPK5NAyYtyMq5vgu
+xEk7wvVyKC9IYUq+kOVP2JL9IlcibDxcvvypxfnETKeI5VZeHDH4MxEPdgJf+1vY
+P3KhV52vestB8mFqB5l0bOEgyuGvO3/3D1JjOnFLS/K2vOj8D/KDRmwXRCcGHTxj
+dj3wJH4UbCIsLgiaQBPkFmTteJDICb+7//6YQuB0t8sR/DZS9Z0GWcfy04Cp/m/E
+4rRoTNz80MbbU9+k0Ly360SxPizcjpPYSRSD025i8Iqv8jvelq7Nzg69Kubc0KfN
+mMrRdMECgYEAz4b7+OX+aO5o2ZQS+fHc8dyWc5umC+uT5xrUm22wZLYA5O8x0Rgj
+vdO/Ho/XyN/GCyvNNV2rI2+CBTxez6NqesGDEmJ2n7TQ03xXLCVsnwVz694sPSMO
+pzTbU6e42jvDo5DMPDv0Pg1CVQuM9ka6wb4DcolMyDql6QddY3iXHBkCgYEAtzAl
+xEAABqdFAnCs3zRf9EZphGJiJ4gtoWmCxQs+IcrfyBNQCy6GqrzJOZ7fQiEoAeII
+V0JmsNcnx3U1W0lp8N+1QNZoB4fOWXaX08BvOEe7gbJ6Xl5t52j792vQp1txpBhE
+UDhz8m5R9i5qb3BzrYBiSTfak0Pq56Xw3jRDjj8CgYEAqX2QS07kQqT8gz85ZGOR
+1QMY6aCks7WaXTR/kdW7K/Wts0xb/m7dugq3W+mVDh0c7UC/36b5v/4xTb9pm+HW
+dB2ZxCkgwvz1VNSHiamjFhlo/Km+rcv1CsDTpHYmNi57cRowg71flFJV64l8fiN0
+IgnjXOcgC6RCnpiCQFxb5fkCgYB+Zq2YleSuspqOjXrrZPNU1YUXgN9jkbaSqwA9
+wH01ygvRvWm83XS0uSFMLhC1S7WUXwgMVdgP69YZ7glMHQMJ3wLtY0RS9eVvm8I1
+rZHQzsZWPvXqydOiGrHJzs4hvJpUdR4mEF4JCRBrAyoUDQ70yCKJjQ24EeQzxS/H
+015N9wKBgB8DdFPvKXyygTMnBoZdpAhkE/x3TTi7DsLBxj7QxKmSHzlHGz0TubIB
+m5/p9dGawQNzD4JwASuY5r4lKXmvYr+4TQPLq6c7EnoIZSwLdge+6PDhnDWJzvk1
+S/RuHWW4FKGzBStTmstG3m0xzxTMnQkV3kPimMim3I3VsxxeGEdq
+-----END RSA PRIVATE KEY-----
diff --git a/tests/integration/test_jwt_auth/helpers/private_key_2 b/tests/integration/test_jwt_auth/helpers/private_key_2
new file mode 100644
index 000000000000..d0d1576f2017
--- /dev/null
+++ b/tests/integration/test_jwt_auth/helpers/private_key_2
@@ -0,0 +1,27 @@
+-----BEGIN RSA PRIVATE KEY-----
+MIIEowIBAAKCAQEA0RRsKcZ5j9UckjioG4Phvav3dkg2sXP6tQ7ug0yowAo/u2Hf
+fB+1OjKuhWTpA3E3YkMKj0RrT+tuUpmZEXqCAipEV7XcfCv3o7Poa7HTq1ti/abV
+wT/KyfGjoNBBSJH4LTNAyo2J8ySKSDtpAEU52iL7s40Ra6I0vqp7/aRuPF5M4zcH
+zN3zarG5EfSVSG1+gTkaRv8XJbra0IeIINmKv0F4++ww8ZxXTR6cvI+MsArUiAPw
+zf7s5dMR4DNRG6YNTrPA0pTOqQE9sRPd62XsfU08plYm27naOUZO5avIPl1YO5I6
+Gi4kPdTvv3WFIy+QvoKoPhPCaD6EbdBpe8BbTQIDAQABAoIBABghJsCFfucKHdOE
+RWZziHx22cblW6aML41wzTcLBFixdhx+lafCEwzF551OgZPbn5wwB4p0R3xAPAm9
+X0yEmnd8gEmtG+aavmg+rZ6sNbULhXenpvi4D4PR5uP61OX2rrEsvpgB0L9mYq0m
+ah5VXvFdYzYcHDwTSsoMa+XgcbZ2qCW6Si3jnbBA1TPIJS5GjfPUQlu9g2FKQL5H
+tlJ7L4Wq39zkueS6LH7kEXOoM+jHgA8F4f7MIrajmilYqnuXanVcMV3+K/6FvH2B
+VBiLggG3CerhB3QyEvZBshvEvvcyRff2NK64CGr/xrAElj4cPHk/E499M1uvUXjE
+boCrD+ECgYEA9LvLljf59h8WWF4bKQZGNKprgFdQIZ2iCEf+VGdGWt/mNg+LyXyn
+3gS/vReON1eaMuEGklZM4Guh/ZPhsPaNmlu16PjmeYTIW1vQTHiO3KR7tAmWep70
+w+gVxDDzuRvBkuDF5oQsZnD3Ri9I7r+J5y9OhyZUsDXe/LJARivF3x0CgYEA2rRx
+wl4mfuYmikvcO8I4vuKXcK1UyYmZQLhp6EHKfhSVgrt7XsstZX9AP2OxUUAocRks
+e6vU/sKUSni7TQrZzAZHc8JXonDgmCqoMPBXIuUncvysGR1kmgVIbN8ISPKJuZoV
+8Dbj3fQfHZ0g0R+mUcuZ+xBO5CKcjPWHZXZoxfECgYAQ/5o8bNbnyXD74k1wpAbs
+UYn1+BqQuyot+RIpOqMgXLzYtGu5Kvdd7GaE88XlAiirsAWM1IGydMdjnYnniLh9
+KDGSZPddKWPhNJdbOGRz3tjYwHG7Qp8tnEkmv1+uU8c2NHaKdFPBKceDEHW4X4Vs
+kVSa/oaTVqqOUrM0LIYp4QKBgQCW1aIriiGEnZhxAvbGJCJczAvkAzcZtBOFBmrM
+ayuLnwiqXEEu1HPfr06RKWFuhxAdSF5cgNrqRSpe3jtXXCdvxdjbpmooNy8+4xSS
+g/+kqmR1snvC6nmqnAAiTgP5w4RnBDUjMcggGLCpDOhIMkrT2Na+x7WRM6nCsceK
+m4qREQKBgEWqdb/QkOMvvKAz2DPDeSrwlTyisrZu1G/86uE3ESb97DisPK+TF2Ts
+r4RGUlKL79W3j5xjvIvqGEEDLC+8QKpay9OYXk3lbViPGB8akWMSP6Tw/8AedhVu
+sjFqcBEFGOELwm7VjAcDeP6bXeXibFe+rysBrfFHUGllytCmNoAV
+-----END RSA PRIVATE KEY-----
diff --git a/tests/integration/test_jwt_auth/jwks_server/server.py b/tests/integration/test_jwt_auth/jwks_server/server.py
new file mode 100644
index 000000000000..96e07f02335e
--- /dev/null
+++ b/tests/integration/test_jwt_auth/jwks_server/server.py
@@ -0,0 +1,33 @@
+import sys
+
+from bottle import response, route, run
+
+
+@route("/.well-known/jwks.json")
+def server():
+    result = {
+        "keys": [
+            {
+                "kty": "RSA",
+                "alg": "RS512",
+                "kid": "mykid",
+                "n": "0RRsKcZ5j9UckjioG4Phvav3dkg2sXP6tQ7ug0yowAo_u2HffB-1OjKuhWTpA3E3YkMKj0RrT-tuUpmZEXqCAipEV7XcfCv3o"
+                     "7Poa7HTq1ti_abVwT_KyfGjoNBBSJH4LTNAyo2J8ySKSDtpAEU52iL7s40Ra6I0vqp7_aRuPF5M4zcHzN3zarG5EfSVSG1-gT"
+                     "kaRv8XJbra0IeIINmKv0F4--ww8ZxXTR6cvI-MsArUiAPwzf7s5dMR4DNRG6YNTrPA0pTOqQE9sRPd62XsfU08plYm27naOUZ"
+                     "O5avIPl1YO5I6Gi4kPdTvv3WFIy-QvoKoPhPCaD6EbdBpe8BbTQ",
+                "e": "AQAB"},
+        ]
+    }
+    response.status = 200
+    response.content_type = "application/json"
+    return result
+
+
+@route("/")
+def ping():
+    response.content_type = "text/plain"
+    response.set_header("Content-Length", 2)
+    return "OK"
+
+
+run(host="0.0.0.0", port=int(sys.argv[1]))
diff --git a/tests/integration/test_jwt_auth/test.py b/tests/integration/test_jwt_auth/test.py
new file mode 100644
index 000000000000..14d42ae08bde
--- /dev/null
+++ b/tests/integration/test_jwt_auth/test.py
@@ -0,0 +1,82 @@
+import os
+import pytest
+
+from helpers.cluster import ClickHouseCluster
+from helpers.mock_servers import start_mock_servers
+
+SCRIPT_DIR = os.path.dirname(os.path.realpath(__file__))
+
+cluster = ClickHouseCluster(__file__)
+instance = cluster.add_instance(
+    "instance",
+    main_configs=["configs/validators.xml"],
+    user_configs=["configs/users.xml"],
+    with_minio=True,
+    # We actually don't need minio, but we need to run dummy resolver
+    # (a shortcut not to change cluster.py in a more unclear way, TBC later).
+)
+client = cluster.add_instance(
+    "client",
+)
+
+
+def run_jwks_server():
+    script_dir = os.path.join(os.path.dirname(__file__), "jwks_server")
+    start_mock_servers(
+        cluster,
+        script_dir,
+        [
+            ("server.py", "resolver", "8080"),
+        ],
+    )
+
+
+@pytest.fixture(scope="module")
+def started_cluster():
+    try:
+        cluster.start()
+        run_jwks_server()
+        yield cluster
+    finally:
+        cluster.shutdown()
+
+
+def curl_with_jwt(token, ip, https=False):
+    http_prefix = "https" if https else "http"
+    curl = f'curl -H "Authorization: Bearer {token}" "{http_prefix}://{ip}:8123/?query=SELECT%20currentUser()"'
+    return curl
+
+
+# See helpers/ directory if you need to re-create tokens (or understand how they are created)
+def test_static_key(started_cluster):
+    res = client.exec_in_container(
+        [
+            "bash",
+            "-c",
+            curl_with_jwt(
+                token="eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqd3RfdXNlciJ9."
+                "kfivQ8qD_oY0UvihydeadD7xvuiO3zSmhFOc_SGbEPQ",
+                ip=cluster.get_instance_ip(instance.name),
+            ),
+        ]
+    )
+    assert res == "jwt_user\n"
+
+
+def test_jwks_server(started_cluster):
+    res = client.exec_in_container(
+        [
+            "bash",
+            "-c",
+            curl_with_jwt(
+                token="eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzUxMiIsImtpZCI6Im15a2lkIn0."
+                      "eyJzdWIiOiJqd3RfdXNlciIsImlzcyI6InRlc3RfaXNzIn0.MjegqrrVyrMMpkxIM-J_q-"
+                      "Sw68Vk5xZuFpxecLLMFs5qzvnh0jslWtyRfi-ANJeJTONPZM5m0yP1ITt8BExoHWobkkR11bXz0ylYEIOgwxqw"
+                      "36XhL2GkE17p-wMvfhCPhGOVL3b7msDRUKXNN48aAJA-NxRbQFhMr-eEx3HsrZXy17Qc7z-"
+                      "0dINe355kzAInGp6gMk3uksAlJ3vMODK8jE-WYFqXusr5GFhXubZXdE2mK0mIbMUGisOZhZLc4QVwvUsYDLBCgJ2RHr5vm"
+                      "jp17j_ZArIedUJkjeC4o72ZMC97kLVnVw94QJwNvd4YisxL6A_mWLTRq9FqNLD4HmbcOQ",
+                ip=cluster.get_instance_ip(instance.name),
+            ),
+        ]
+    )
+    assert res == "jwt_user\n"

From efc8bdf6bf1474ad162553b39b9b2defe4a95d15 Mon Sep 17 00:00:00 2001
From: Andrey Zvonov <32552679+zvonand@users.noreply.github.com>
Date: Wed, 1 Apr 2026 02:03:30 +0200
Subject: [PATCH 2/2] Merge pull request #1596 from
 Altinity/fix/antalya-26.1/oauth-30032026

Antalya 26.1: Add support for EC JWT validation

Source-PR: #1596 (https://github.com/Altinity/ClickHouse/pull/1596)
---
 .../external-authenticators/tokens.md         |   2 +-
 src/Access/TokenProcessorsJWT.cpp             | 139 +++++++++++++++++-
 .../test_jwt_auth/jwks_server/server.py       |   8 +
 tests/integration/test_jwt_auth/test.py       |  17 +++
 4 files changed, 159 insertions(+), 7 deletions(-)

diff --git a/docs/en/operations/external-authenticators/tokens.md b/docs/en/operations/external-authenticators/tokens.md
index 48c86c92b9cf..74c02a56900b 100644
--- a/docs/en/operations/external-authenticators/tokens.md
+++ b/docs/en/operations/external-authenticators/tokens.md
@@ -102,7 +102,7 @@ Only one of `static_jwks` or `static_jwks_file` keys must be present in one veri
 :::
 
 :::note
-Only RS* family algorithms are supported!
+For JWKS-based validators (`jwt_static_jwks` and `jwt_dynamic_jwks`), RS* and ES* family algorithms are supported.
 :::
 
 ### JWT with remote JWKS
diff --git a/src/Access/TokenProcessorsJWT.cpp b/src/Access/TokenProcessorsJWT.cpp
index c666aca15c3a..182556f24b6a 100644
--- a/src/Access/TokenProcessorsJWT.cpp
+++ b/src/Access/TokenProcessorsJWT.cpp
@@ -4,6 +4,12 @@
 #include <Common/Base64.h>
 #include <Common/logger_useful.h>
 #include <Poco/String.h>
+#include <openssl/bio.h>
+#include <openssl/core_names.h>
+#include <openssl/evp.h>
+#include <openssl/param_build.h>
+#include <openssl/pem.h>
+#include <cstring>
 
 namespace DB {
 
@@ -156,6 +162,77 @@ bool check_claims(const String & claims, const picojson::value::object & payload
     return check_claims(json.get<picojson::value::object>(), payload, "");
 }
 
+std::string create_public_key_from_ec_components(const std::string & x, const std::string & y, int curve_nid)
+{
+    auto decode_base64url = [](const std::string & value)
+    {
+        return jwt::base::decode<jwt::alphabet::base64url>(jwt::base::pad<jwt::alphabet::base64url>(value));
+    };
+
+    auto decoded_x = decode_base64url(x);
+    auto decoded_y = decode_base64url(y);
+
+    size_t coordinate_size = 0;
+    if (curve_nid == NID_X9_62_prime256v1)
+        coordinate_size = 32;
+    else if (curve_nid == NID_secp384r1)
+        coordinate_size = 48;
+    else if (curve_nid == NID_secp521r1)
+        coordinate_size = 66;
+    else
+        throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "JWT cannot be validated: unsupported EC curve");
+
+    if (decoded_x.size() > coordinate_size || decoded_y.size() > coordinate_size)
+        throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "JWT cannot be validated: invalid EC key coordinates length");
+
+    std::vector<unsigned char> public_key_octets(1 + 2 * coordinate_size, 0);
+    public_key_octets[0] = 0x04; // Uncompressed point format.
+    std::memcpy(public_key_octets.data() + 1 + (coordinate_size - decoded_x.size()), decoded_x.data(), decoded_x.size());
+    std::memcpy(public_key_octets.data() + 1 + coordinate_size + (coordinate_size - decoded_y.size()), decoded_y.data(), decoded_y.size());
+
+    const char * group_name = OBJ_nid2sn(curve_nid);
+    if (!group_name)
+        throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "JWT cannot be validated: unsupported EC curve");
+
+    std::unique_ptr<OSSL_PARAM_BLD, decltype(&OSSL_PARAM_BLD_free)> params_bld(OSSL_PARAM_BLD_new(), OSSL_PARAM_BLD_free);
+    if (!params_bld)
+        throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "JWT cannot be validated: failed to allocate OpenSSL parameter builder");
+
+    if (OSSL_PARAM_BLD_push_utf8_string(params_bld.get(), OSSL_PKEY_PARAM_GROUP_NAME, group_name, 0) != 1)
+        throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "JWT cannot be validated: failed to set EC group parameter");
+
+    if (OSSL_PARAM_BLD_push_octet_string(params_bld.get(), OSSL_PKEY_PARAM_PUB_KEY, public_key_octets.data(), public_key_octets.size()) != 1)
+        throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "JWT cannot be validated: failed to set EC public key parameter");
+
+    std::unique_ptr<OSSL_PARAM, decltype(&OSSL_PARAM_free)> params(OSSL_PARAM_BLD_to_param(params_bld.get()), OSSL_PARAM_free);
+    if (!params)
+        throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "JWT cannot be validated: failed to build OpenSSL parameters");
+
+    std::unique_ptr<EVP_PKEY_CTX, decltype(&EVP_PKEY_CTX_free)> key_ctx(EVP_PKEY_CTX_new_from_name(nullptr, "EC", nullptr), EVP_PKEY_CTX_free);
+    if (!key_ctx)
+        throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "JWT cannot be validated: failed to create EVP key context");
+
+    if (EVP_PKEY_fromdata_init(key_ctx.get()) <= 0)
+        throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "JWT cannot be validated: failed to initialize EVP key import");
+
+    EVP_PKEY * raw_evp_key = nullptr;
+    if (EVP_PKEY_fromdata(key_ctx.get(), &raw_evp_key, EVP_PKEY_PUBLIC_KEY, params.get()) <= 0)
+        throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "JWT cannot be validated: failed to import EC public key");
+
+    std::unique_ptr<EVP_PKEY, decltype(&EVP_PKEY_free)> evp_key(raw_evp_key, EVP_PKEY_free);
+
+    std::unique_ptr<BIO, decltype(&BIO_free)> bio(BIO_new(BIO_s_mem()), BIO_free);
+    if (!bio)
+        throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "JWT cannot be validated: failed to allocate BIO");
+
+    if (PEM_write_bio_PUBKEY(bio.get(), evp_key.get()) != 1)
+        throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "JWT cannot be validated: failed to encode EC public key");
+
+    char * data = nullptr;
+    auto len = BIO_get_mem_data(bio.get(), &data);
+    return std::string(data, len);
+}
+
 }
 
 namespace
@@ -392,12 +469,56 @@ bool JwksJwtProcessor::resolveAndValidate(TokenCredentials & credentials) const
 
     if (public_key.empty())
     {
-        if (!(jwk.has_jwk_claim("n") && jwk.has_jwk_claim("e")))
-            throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "{}: invalid JWK: 'n' or 'e' not found", processor_name);
-        LOG_TRACE(getLogger("TokenAuthentication"), "{}: `issuer` or `x5c` not present, verifying {} with RSA components", processor_name, username);
-        const auto modulus = jwk.get_jwk_claim("n").as_string();
-        const auto exponent = jwk.get_jwk_claim("e").as_string();
-        public_key = jwt::helper::create_public_key_from_rsa_components(modulus, exponent);
+        const auto key_type = jwk.get_key_type();
+        if (key_type == "EC")
+        {
+            if (!(jwk.has_jwk_claim("x") && jwk.has_jwk_claim("y")))
+                throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "{}: invalid JWK: missing 'x'/'y' claims for EC key type", processor_name);
+
+            int curve_nid = NID_undef;
+            std::optional<String> expected_crv;
+            if (algo == "es256")
+            {
+                curve_nid = NID_X9_62_prime256v1;
+                expected_crv = "P-256";
+            }
+            else if (algo == "es384")
+            {
+                curve_nid = NID_secp384r1;
+                expected_crv = "P-384";
+            }
+            else if (algo == "es512")
+            {
+                curve_nid = NID_secp521r1;
+                expected_crv = "P-521";
+            }
+
+            if (curve_nid == NID_undef)
+                throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "JWT cannot be validated: unknown algorithm {}", algo);
+
+            if (jwk.has_jwk_claim("crv"))
+            {
+                const auto crv = jwk.get_jwk_claim("crv").as_string();
+                if (expected_crv.has_value() && crv != expected_crv.value())
+                    throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "JWT cannot be validated: `crv` in JWK does not match JWT algorithm");
+            }
+
+            LOG_TRACE(getLogger("TokenAuthentication"), "{}: `x5c` not present, verifying {} with EC components", processor_name, username);
+            const auto x = jwk.get_jwk_claim("x").as_string();
+            const auto y = jwk.get_jwk_claim("y").as_string();
+            public_key = create_public_key_from_ec_components(x, y, curve_nid);
+        }
+        else if (key_type == "RSA")
+        {
+            if (!(jwk.has_jwk_claim("n") && jwk.has_jwk_claim("e")))
+                throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "{}: invalid JWK: missing 'n'/'e' claims for RSA key type", processor_name);
+            LOG_TRACE(getLogger("TokenAuthentication"), "{}: `issuer` or `x5c` not present, verifying {} with RSA components", processor_name, username);
+            const auto modulus = jwk.get_jwk_claim("n").as_string();
+            const auto exponent = jwk.get_jwk_claim("e").as_string();
+            public_key = jwt::helper::create_public_key_from_rsa_components(modulus, exponent);
+        }
+        else
+            throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "{}: invalid JWK key type '{}'", processor_name, key_type);
     }
 
     if (jwk.has_algorithm() && Poco::toLower(jwk.get_algorithm()) != algo)
@@ -409,6 +530,12 @@ bool JwksJwtProcessor::resolveAndValidate(TokenCredentials & credentials) const
         verifier = verifier.allow_algorithm(jwt::algorithm::rs384(public_key, "", "", ""));
     else if (algo == "rs512")
         verifier = verifier.allow_algorithm(jwt::algorithm::rs512(public_key, "", "", ""));
+    else if (algo == "es256")
+        verifier = verifier.allow_algorithm(jwt::algorithm::es256(public_key, "", "", ""));
+    else if (algo == "es384")
+        verifier = verifier.allow_algorithm(jwt::algorithm::es384(public_key, "", "", ""));
+    else if (algo == "es512")
+        verifier = verifier.allow_algorithm(jwt::algorithm::es512(public_key, "", "", ""));
     else
         throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "JWT cannot be validated: unknown algorithm {}", algo);
 
diff --git a/tests/integration/test_jwt_auth/jwks_server/server.py b/tests/integration/test_jwt_auth/jwks_server/server.py
index 96e07f02335e..67c4d6a4cf8d 100644
--- a/tests/integration/test_jwt_auth/jwks_server/server.py
+++ b/tests/integration/test_jwt_auth/jwks_server/server.py
@@ -16,6 +16,14 @@ def server():
                      "kaRv8XJbra0IeIINmKv0F4--ww8ZxXTR6cvI-MsArUiAPwzf7s5dMR4DNRG6YNTrPA0pTOqQE9sRPd62XsfU08plYm27naOUZ"
                      "O5avIPl1YO5I6Gi4kPdTvv3WFIy-QvoKoPhPCaD6EbdBpe8BbTQ",
                 "e": "AQAB"},
+            {
+                "kty": "EC",
+                "alg": "ES384",
+                "kid": "ecmykid",
+                "crv": "P-384",
+                "x": "ewdB5ypKwp641N5cYmKJvTiwWLIc_IJduJwur2mit1SgQpPZdUwpDV3aNIAmry4Y",
+                "y": "Jajx21k25o2K-ik86kaaawu6O84awaSmvSirJn8WCeEuotu3O-4Gn-ryOMuDsH76",
+            },
         ]
     }
     response.status = 200
diff --git a/tests/integration/test_jwt_auth/test.py b/tests/integration/test_jwt_auth/test.py
index 14d42ae08bde..481c8117a73e 100644
--- a/tests/integration/test_jwt_auth/test.py
+++ b/tests/integration/test_jwt_auth/test.py
@@ -80,3 +80,20 @@ def test_jwks_server(started_cluster):
         ]
     )
     assert res == "jwt_user\n"
+
+
+def test_jwks_server_ec_es384(started_cluster):
+    res = client.exec_in_container(
+        [
+            "bash",
+            "-c",
+            curl_with_jwt(
+                token="eyJhbGciOiJFUzM4NCIsImtpZCI6ImVjbXlraWQiLCJ0eXAiOiJKV1QifQ."
+                      "eyJzdWIiOiJqd3RfdXNlciIsImlzcyI6InRlc3RfaXNzIn0."
+                      "3iGUcKfc07oLN4XmBA6BJSGSfu7cBsdQ6KAFh1sV64rWYkVL5VzYlAskHaWZ4R9hR3QK0Bv0EPjia8Vo-xdN9jS7-fVB7RF0"
+                      "rGvbTOIuxE-yDumCyji3MYoLpcbOVasU",
+                ip=cluster.get_instance_ip(instance.name),
+            ),
+        ]
+    )
+    assert res == "jwt_user\n"
