OAuth 2.0 Authorization for Altinity MCP Server
July 8, 2026 · View on GitHub
How OAuth 2.0 / OpenID Connect (OIDC) authentication works in
altinity-mcp and how to wire it up against common identity providers.
Companion: the ClickHouse-side JWT verifier sidecar lives in
altinity-oauth-helper —
that repo carries the spec, source, helm chart, and Dockerfile.
Overview
Set oauth.broker: true. That single flag makes altinity-mcp:
- Act as the OAuth Authorization Server to MCP clients (claude.ai,
ChatGPT, Codex) — hosts CIMD resolution,
/oauth/authorize,/oauth/callback,/oauth/token. - Broker the upstream IdP (Google, Azure AD, Keycloak, etc.) using a static OAuth application credential you register there.
- Auto-detect the ClickHouse wire format on the first authenticated
request to each endpoint and cache it — no
mode:config needed.
config:
clickhouse:
host: clickhouse.example.com
port: 8123
protocol: http
server:
oauth:
enabled: true
broker: true
signing_secret: "<32-byte-random>" # openssl rand -base64 32
issuer: "https://mcp.example.com"
audience: "https://mcp.example.com"
client_id: "<UPSTREAM_OAUTH_CLIENT_ID>"
client_secret: "<UPSTREAM_OAUTH_CLIENT_SECRET>"
auth_url: "<UPSTREAM_AUTH_URL>"
token_url: "<UPSTREAM_TOKEN_URL>"
public_resource_url: "https://mcp.example.com"
public_auth_server_url: "https://mcp.example.com"
scopes: [openid, email, profile]
ClickHouse authentication (auto-detected)
altinity-mcp supports two CH-side auth methods. With broker: true it
probes the endpoint on first use and caches the result — operators do not
need to configure which one is in use.
Bearer (Authorization: Bearer <token>)
Requires a ClickHouse build with token_processors (Altinity Antalya
25.8+). MCP forwards the upstream JWT directly; ClickHouse re-validates
against the upstream JWKS and materializes ephemeral users from claims.
- No sidecar needed.
- Users are provisioned dynamically from JWT claims — no
CREATE USERper identity. - Identity in
system.query_logis the JWT subject (set<username_claim>email</username_claim>to get readable names).
Basic (Authorization: Basic base64(email:JWT))
Works on any ClickHouse build. MCP unverified-decodes the JWT's email
claim and rewrites the credential to Basic form. ClickHouse's
<http_authentication>
posts the Basic header to the colocated ch-jwt-verify sidecar, which
cryptographically validates signature, iss, aud (RFC 8707 byte-equal),
exp/nbf/iat, required scopes, identity policy, and the
user-vs-claim match.
- Sidecar must be deployed next to ClickHouse (see
altinity-oauth-helper). - Users are pre-provisioned:
CREATE USER "alice@example.com" IDENTIFIED WITH http SERVER 'ch_jwt_verify' SCHEME 'BASIC'. - Forces HTTP protocol on the driver.
By default the Basic username is the JWT email claim (with a namespaced
*/email fallback for Auth0 DCR tokens). Set oauth.username_claim to
use a different claim (username, preferred_username, sub, …) when your
ClickHouse users are provisioned from that claim. When set, MCP does a strict
top-level lookup of that single claim and fails closed on a
missing/empty/non-string value — it never silently falls back to email or
another identity. An explicit username_claim: email is therefore strict and
does not do the namespaced fallback; leave it unset if you rely on that.
oauth.username_claim must match the sidecar's identity.username_claim
(see below) — a mismatch fails authentication. The value is an unverified
hint only; the sidecar still validates the JWT and the user-vs-claim match.
Detection logic
On the first authenticated request to host:port, MCP tries Bearer. If
CH returns an auth error (HTTP 401/403, CH exception codes 497/516/519),
it falls back to Basic. The result is stored in an in-memory cache keyed
by host:port and reused for all subsequent requests. The cache is
cleared on config reload.
MCP client discovery flow
OAuth-capable MCP clients discover authentication automatically per RFC 9728:
- Client
GETs/.well-known/oauth-protected-resourcefrom the MCP endpoint. - Response
authorization_serverspoints to MCP itself. - Client fetches MCP's
/.well-known/oauth-authorization-server, which advertises CIMD support (no DCRregistration_endpoint), listsgrant_types_supported: ["authorization_code"]andtoken_endpoint_auth_methods_supported: ["none", "private_key_jwt"]. - Client publishes a CIMD document at a URL it controls and uses that
URL as its
client_idat/authorize. - Client initiates the authorization-code flow with S256 PKCE.
- After login, client exchanges the code for an access token.
- Client presents the access token on every MCP request.
Requirements
- ClickHouse protocol: HTTP (port 8123 typically). Both Bearer and Basic routes use CH's HTTP interface; TCP/native has no equivalent.
- ClickHouse build:
- Bearer: Altinity Antalya 25.8+ or any CH build with
token_processors. - Basic: any build with
<http_authentication>andIDENTIFIED WITH http(CH 24.x+ for OSS). Requires thech-jwt-verifysidecar.
- Bearer: Altinity Antalya 25.8+ or any CH build with
- Identity Provider: any OAuth 2.0 / OIDC-compliant IdP that supports the authorization-code flow.
signing_secret: required whenbroker: true. Symmetric secret (≥ 32 bytes) for all stateless OAuth artifacts. Generate withopenssl rand -base64 32.- Reverse proxy: if published behind a proxy, set
public_resource_urlandpublic_auth_server_url. See Frontend / Reverse Proxy.
Sidecar + ClickHouse-side config (Basic auth path)
If your ClickHouse uses the ch-jwt-verify sidecar, altinity-mcp will
detect and use Basic auth automatically. No MCP-side config change needed.
The sidecar deploys as a colocated container in the CH pod. See
altinity-oauth-helper
for the full spec, helm chart, and wiring example.
ClickHouse registers the sidecar via a config.d/ XML drop-in:
<clickhouse>
<http_authentication_servers>
<ch_jwt_verify>
<uri>http://127.0.0.1:9999/verify</uri>
<forward_headers>
<header>Authorization</header>
</forward_headers>
</ch_jwt_verify>
</http_authentication_servers>
</clickhouse>
Per-user provisioning:
CREATE ROLE IF NOT EXISTS mcp_reader;
GRANT SELECT ON analytics.* TO mcp_reader;
CREATE USER `alice@example.com`
IDENTIFIED WITH http SERVER 'ch_jwt_verify' SCHEME 'BASIC'
DEFAULT ROLE mcp_reader;
The grammar token is http, not http_authenticator — ClickHouse
rejects the latter with SYNTAX_ERROR. SERVER 'ch_jwt_verify' must
match the <http_authentication_servers><ch_jwt_verify> block name.
Identity policy (verified-email, domain allow-listing, user-vs-claim match) lives in the sidecar's config:
identity:
username_claim: email
match_mode: lowercase_equal
require_email_verified: true
allowed_email_domains: ["example.com"]
The sidecar's identity.username_claim here must match MCP's
oauth.username_claim (default email). MCP puts that claim's value in the
Basic username; the sidecar re-reads the same claim from the verified token
and rejects the request if they disagree. If you point the sidecar at a
non-email claim, set MCP's oauth.username_claim to the same claim.
ClickHouse token_processors (Bearer auth path)
If your ClickHouse uses token_processors, altinity-mcp will detect
Bearer auth automatically. No MCP-side config change needed.
Generic OIDC (Keycloak, etc.)
<clickhouse>
<token_processors>
<my_oidc_provider>
<type>openid</type>
<configuration_endpoint>https://idp.example.com/.well-known/openid-configuration</configuration_endpoint>
<token_cache_lifetime>60</token_cache_lifetime>
</my_oidc_provider>
</token_processors>
<user_directories>
<token>
<processor>my_oidc_provider</processor>
<common_roles>
<default_role />
</common_roles>
</token>
</user_directories>
</clickhouse>
Azure AD (Microsoft Entra ID)
<clickhouse>
<token_processors>
<azure_ad>
<type>azure</type>
<token_cache_lifetime>60</token_cache_lifetime>
</azure_ad>
</token_processors>
<user_directories>
<token>
<processor>azure_ad</processor>
<common_roles>
<default_role />
</common_roles>
</token>
</user_directories>
</clickhouse>
Roles must exist before users can authenticate:
CREATE ROLE OR REPLACE default_role;
GRANT SELECT ON default.* TO default_role;
The default <username_claim> is sub — IdP users appear in
system.processes as numeric IDs. Set
<username_claim>email</username_claim> to attribute queries by email.
Full configuration reference
server:
oauth:
# Enable OAuth 2.0 authentication
enabled: false
# Canonical broker flag: MCP acts as AS to MCP clients (CIMD + /authorize
# + /callback + /token) and brokers the upstream IdP. CH auth format
# (Bearer vs Basic) is auto-detected on first request per endpoint.
broker: true
# Symmetric secret for stateless OAuth artifacts (authorization codes,
# JWE-wrapped state, HKDF-derived signing material). Required when
# broker: true. Minimum 32 bytes — generate with `openssl rand -base64 32`.
signing_secret: ""
# Upstream OAuth/OIDC issuer URL (used for discovery and token validation)
issuer: ""
# URL to fetch JWKS for token validation (discovered from issuer if omitted)
jwks_url: ""
# Expected audience claim in incoming tokens
# (RFC 8707 byte-equality; trailing slash matters)
audience: ""
# Upstream OAuth client credentials. Required when broker: true.
client_id: ""
client_secret: ""
token_url: ""
auth_url: ""
userinfo_url: ""
# OAuth scopes to request from the upstream IdP
scopes: ["openid", "profile", "email"]
# Append offline_access to the upstream authorize scope so the IdP
# consent screen offers long-lived sessions. v1 does NOT issue downstream
# refresh tokens — clients re-authorize via /oauth/authorize on expiry.
upstream_offline_access: false
# Scopes required in every incoming bearer JWT
required_scopes: []
# Per-request ClickHouse role activation. role_claim names a JWT claim
# holding a JSON array of role names, activated per request via HTTP role=
# params. role_filter is OPTIONAL: when set, only the matching subset is
# activated (narrowing); when empty, all roles in the claim are activated.
# Either way an empty resolved set fails closed (request denied, no fallback
# to the full grant). Leave role_claim empty to disable the feature. Anchor
# role_filter (^…/…$) — it is a partial match, so an unanchored "anon" would
# also match "not_anon_real".
role_claim: "" # e.g. "https://clickhouse/roles"
role_filter: "" # optional; e.g. "^anon_" (empty = activate all claim roles)
# JWT claim used as the ClickHouse Basic-auth username on the sidecar path.
# Empty = default `email` claim + namespaced */email fallback. When set, a
# strict top-level lookup of this single claim is used and a missing/empty/
# non-string value fails closed. Must match the sidecar's
# identity.username_claim. Applies only to the Basic path, not Bearer.
username_claim: "" # e.g. "username", "preferred_username", "sub"
# Token lifetimes (broker mode)
access_token_ttl_seconds: 3600
refresh_token_ttl_seconds: 2592000 # 30 d
# Externally visible MCP endpoint URL. Required behind a reverse proxy.
public_resource_url: ""
# Externally visible OAuth authorization server URL. Required behind
# a reverse proxy when broker: true.
public_auth_server_url: ""
# Endpoint paths (defaults shown)
authorization_path: "/authorize"
callback_path: "/callback"
token_path: "/token"
Key options
| Option | Description |
|---|---|
broker | true: MCP acts as the OAuth AS, brokers upstream IdP, auto-detects CH auth format. |
signing_secret | Symmetric HKDF master secret for OAuth JWE artifacts. Required when broker: true. ≥ 32 bytes. |
issuer | Upstream IdP issuer URL for OIDC discovery and token validation. |
audience | RFC 8707 byte-equal target. Must match the JWT's aud claim byte-for-byte (trailing slashes count). |
public_resource_url | Externally visible MCP endpoint URL. Required behind a reverse proxy. |
public_auth_server_url | Externally visible OAuth AS URL. Required behind a reverse proxy when broker: true. |
upstream_offline_access | Request offline_access upstream so the IdP consent screen offers long-lived sessions. Default false. |
role_claim | JWT claim holding a JSON array of ClickHouse role names to activate per request (e.g. https://clickhouse/roles). Empty disables. Read from the validated token's namespaced/custom claims. |
role_filter | Optional regex narrowing which role_claim roles are activated (e.g. _mcp$). When empty, all roles in the claim are activated (the IdP curates the set; CH re-validates the token and enforces grants). Only ever narrows; an empty resolved set (claim absent/empty, or filter matched nothing) fails closed — request denied, no fallback to the full grant. HTTP protocol only; applies in both Bearer and Basic/sidecar paths. Anchor it (^…/…$) — it's a partial match, so an unanchored anon would also match not_anon_real. |
username_claim | JWT claim used as the ClickHouse Basic-auth username on the ch-jwt-verify sidecar path. Empty (default) = email claim with a namespaced */email fallback. When set (e.g. username, preferred_username, sub), a strict top-level lookup of that single claim is used and a missing/empty/non-string value fails closed — never falling back to another identity. An explicit email is strict (no namespaced fallback). Unverified hint only (CH/sidecar still validate). Must match the sidecar's identity.username_claim. Basic path only; no effect on Bearer. |
Provider-specific setup
Keycloak
-
Create a realm and client in the Keycloak admin console:
- Client Protocol:
openid-connect - Access Type:
confidential - Valid Redirect URIs: your MCP server's
<public>/oauth/callback - Enable "Standard Flow"
- Client Protocol:
-
MCP config:
server: oauth: enabled: true broker: true signing_secret: "<32-byte-random>" issuer: "http://keycloak:8080/realms/mcp" audience: "clickhouse-mcp" client_id: "clickhouse-mcp" client_secret: "<KEYCLOAK_CLIENT_SECRET>" auth_url: "http://keycloak:8080/realms/mcp/protocol/openid-connect/auth" token_url: "http://keycloak:8080/realms/mcp/protocol/openid-connect/token" scopes: ["openid", "email"] -
ClickHouse
token_processors(if using Bearer):<token_processors> <keycloak> <type>OpenID</type> <userinfo_endpoint>http://keycloak:8080/realms/mcp/protocol/openid-connect/userinfo</userinfo_endpoint> <jwks_uri>http://keycloak:8080/realms/mcp/protocol/openid-connect/certs</jwks_uri> <token_cache_lifetime>60</token_cache_lifetime> </keycloak> </token_processors>
See zvonand/grafana-oauth for a complete working example with Keycloak and ClickHouse.
Azure AD (Microsoft Entra ID)
-
Register an application in the Azure Portal:
- Microsoft Entra ID → App registrations → New registration
- Add a redirect URI: your MCP
<public>/oauth/callback - Create a client secret under Certificates & secrets.
- Configure API permissions:
openid,profile,email.
-
MCP config:
server: oauth: enabled: true broker: true signing_secret: "<32-byte-random>" issuer: "https://login.microsoftonline.com/<TENANT_ID>/v2.0" audience: "<APP_CLIENT_ID>" client_id: "<APP_CLIENT_ID>" client_secret: "<APP_CLIENT_SECRET>" token_url: "https://login.microsoftonline.com/<TENANT_ID>/oauth2/v2.0/token" auth_url: "https://login.microsoftonline.com/<TENANT_ID>/oauth2/v2.0/authorize" scopes: ["openid", "profile", "email"]
See zvonand/grafana-oauth/azure for a complete working example.
Google Cloud Identity
-
Create OAuth 2.0 credentials in the Google Cloud Console under APIs & Services → Credentials → OAuth client ID → Web application. Set the authorized redirect URI to
<public>/oauth/callback. -
MCP config:
server: oauth: enabled: true broker: true signing_secret: "<32-byte-random>" issuer: "https://accounts.google.com" audience: "<GOOGLE_CLIENT_ID>.apps.googleusercontent.com" client_id: "<GOOGLE_CLIENT_ID>.apps.googleusercontent.com" client_secret: "<GOOGLE_CLIENT_SECRET>" token_url: "https://oauth2.googleapis.com/token" auth_url: "https://accounts.google.com/o/oauth2/v2/auth" scopes: ["openid", "profile", "email"] -
ClickHouse
token_processors(if using Bearer):<token_processors> <google> <type>openid</type> <configuration_endpoint>https://accounts.google.com/.well-known/openid-configuration</configuration_endpoint> <token_cache_lifetime>60</token_cache_lifetime> <username_claim>email</username_claim> </google> </token_processors>
References: Google OpenID Connect, Using OAuth 2.0 to Access Google APIs.
AWS Cognito
-
Create a user pool in the AWS Console with Authorization Code grant, scopes
openid profile email, callback URL<public>/oauth/callback. -
MCP config:
server: oauth: enabled: true broker: true signing_secret: "<32-byte-random>" issuer: "https://cognito-idp.<REGION>.amazonaws.com/<USER_POOL_ID>" audience: "<APP_CLIENT_ID>" client_id: "<APP_CLIENT_ID>" client_secret: "<APP_CLIENT_SECRET>" token_url: "https://<DOMAIN>.auth.<REGION>.amazoncognito.com/oauth2/token" auth_url: "https://<DOMAIN>.auth.<REGION>.amazoncognito.com/oauth2/authorize" scopes: ["openid", "profile", "email"] -
ClickHouse
token_processors(if using Bearer):<token_processors> <cognito> <type>openid</type> <configuration_endpoint>https://cognito-idp.<REGION>.amazonaws.com/<USER_POOL_ID>/.well-known/openid-configuration</configuration_endpoint> <token_cache_lifetime>60</token_cache_lifetime> </cognito> </token_processors>
References: Amazon Cognito - OIDC IdPs.
Helm chart deployment
helm install altinity-mcp ./helm/altinity-mcp \
-f helm/altinity-mcp/values_examples/mcp-oauth-keycloak.yaml
values_examples/mcp-oauth-keycloak.yaml— Keycloak / generic OIDCvalues_examples/mcp-oauth-azure.yaml— Azure ADvalues_examples/mcp-oauth-google.yaml— Google Cloud Identity
For the ch-jwt-verify sidecar path, also deploy from
altinity-oauth-helper
into the ClickHouse pod.
Frontend / Reverse Proxy Requirements
The proxy must expose two public URL spaces:
- the protected resource, e.g.
https://mcp.example.com/ - the OAuth authorization server, e.g.
https://mcp.example.com/oauth
The proxy must:
- Forward
HostandAuthorizationheaders unchanged - Disable response buffering for MCP streaming
- Disable request buffering for long-lived POSTs
- Keep long read/send timeouts
- Not normalize or rewrite the configured callback or metadata paths
Example nginx fragment:
location / {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Authorization $http_authorization;
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 3600;
proxy_send_timeout 3600;
proxy_pass http://ALTINITY_MCP_UPSTREAM;
}
If the IdP reports redirect_uri_mismatch, verify the public callback
URL the browser sees exactly matches the URI registered at the IdP.
Security considerations
signing_secretprotects all stateless OAuth artifacts. Treat it like a signing key. Rotate it to invalidate all outstanding tokens.- MCP holds no per-tenant ClickHouse credential. The bearer is the
upstream IdP's JWT; validation happens at ClickHouse (either
token_processorsor thech-jwt-verifysidecar). - Opaque bearer tokens are not supported. Inbound OAuth requires a signed JWT validatable via JWKS. RFC 7662 introspection is not implemented.
- CIMD is the only inbound registration model. Dynamic Client
Registration (DCR, RFC 7591) is intentionally not exposed —
/oauth/registerreturns HTTP 410 Gone. - Token preference. When both
id_tokenandaccess_tokencome back from the upstream, altinity-mcp prefersid_tokenand falls back toaccess_tokenonly when noid_tokenis available.
Troubleshooting
ClickHouse returns HTTP 403 with Bearer HTTP Authorization scheme is not supported
The CH build does not support token_processors. altinity-mcp will
automatically fall back to Basic auth if the ch-jwt-verify sidecar is
running. If neither is configured, upgrade to Altinity Antalya 25.8+ or
deploy altinity-oauth-helper.
Token validation fails with issuer mismatch
oauth.issuer doesn't exactly match the iss claim. Common causes:
- Trailing slash mismatch (
https://idp.example.comvshttps://idp.example.com/). - Missing
/v2.0suffix for Azure AD. - Wrong realm path for Keycloak.
oauth: bearer is not a JWT with an email claim
The JWT's top-level email claim is missing. Some IdPs strip standard
OIDC claims for third-party clients. Configure a post-login action that
injects https://<your-namespace>/email into the access token —
altinity-mcp reads any claim with a /email suffix as a fallback.
ClickHouse authenticates but the user has no permissions
Create the roles and grant them the necessary permissions:
CREATE ROLE OR REPLACE default_role;
GRANT SELECT ON *.* TO default_role;
For the sidecar path, also verify the user exists:
CREATE USER "alice@example.com" IDENTIFIED WITH http SERVER 'ch_jwt_verify' SCHEME 'BASIC'.
block decode for exception: unexpected value 10 for boolean
A FORMAT JSON (or similar) suffix in the SQL — the driver speaks native
binary over HTTP and the format override makes ClickHouse return text.
Drop the FORMAT clause.
More troubleshooting
For sidecar-specific errors (JWKS rotation, audience byte-equality,
sidecar binding gotchas) see the
altinity-oauth-helper
repo's troubleshooting section.