diff --git a/docs/authentication.md b/docs/authentication.md
index 3b129600c..c24ac814e 100644
--- a/docs/authentication.md
+++ b/docs/authentication.md
@@ -1,8 +1,9 @@
 # Authentication
 
-Albert Python SDK supports three authentication methods:
+Albert Python SDK supports four authentication methods:
 
 * **Single Sign-On (SSO)** via browser-based OAuth2
+* **OIDC Token Exchange** for server-to-server integrations using any OIDC-compliant identity provider
 * **Client Credentials** using a client ID and secret
 * **Static Token** using a pre-generated token (via the `ALBERT_TOKEN` environment variable)
 
@@ -46,6 +47,106 @@ client = Albert.from_sso(
 
 ---
 
+## 🔄 OIDC Token Exchange
+
+This method is for applications that already authenticate users through an **OIDC-compliant
+identity provider** and want to access the Albert API on their behalf — without any browser
+interaction. Your application obtains an OIDC ID token and the SDK exchanges it for an Albert
+access token automatically.
+
+!!! warning "Tenant configuration required"
+    This authentication method requires your identity provider's `aud` (audience/client ID)
+    to be registered with Albert for your tenant. Without this, all requests will return `401 Unauthorized`.
+    [Contact Albert support](https://support.albertinvent.com/en/contact-us) to enable this for your organisation.
+
+### Supported Identity Providers
+
+Any OIDC-compliant identity provider that includes the `preferred_username` claim in the ID
+token is supported. This includes:
+
+| Provider | Notes |
+|---|---|
+| Microsoft Entra ID (Azure AD) | Supported via v2.0 endpoints |
+| Okta | OIDC app integrations only (not SAML) |
+| Auth0 | Supported |
+| AWS Cognito | User pools with OIDC |
+| Ping Identity | PingOne and PingFederate OIDC |
+| Keycloak | Supported |
+| OneLogin, ForgeRock, IBM Security Verify | Supported |
+| Salesforce Identity | OIDC configuration required |
+| Self-hosted OIDC servers | Authentik, Dex, Hydra, etc. |
+
+!!! warning "Google Identity / Workspace"
+    `preferred_username` is not emitted by default in Google's OIDC tokens. You must configure
+    a custom claim mapping in your Google OAuth app to include it before this flow will work.
+
+!!! info "SAML providers are not supported"
+    Identity providers configured to use SAML only (rather than OIDC) are not compatible with
+    this authentication method.
+
+### Prerequisites
+
+- An application registration with your identity provider, with the Albert API audience
+  registered by Albert support
+- A mechanism in your application to obtain an OIDC ID token from your provider
+
+### Usage
+
+Provide a callable that returns a fresh OIDC ID token on demand. The SDK calls it on the
+first request and again whenever the Albert access token needs to be renewed.
+
+```python
+from albert import Albert
+import requests
+
+def get_token() -> str:
+    resp = requests.post(
+        "https://mycompany.okta.com/oauth2/default/v1/token",
+        data={
+            "grant_type": "client_credentials",
+            "client_id": "your-okta-client-id",
+            "client_secret": "your-okta-client-secret",
+            "scope": "openid",
+        },
+    )
+    return resp.json()["id_token"]
+
+client = Albert.from_sso_exchange(
+    base_url="https://mycompany.albertinvent.com",
+    subdomain="mycompany",
+    oidc_token_provider=get_token,
+)
+```
+
+Or wire it up manually using `AlbertSSOTokenExchange` directly:
+
+```python
+from albert import Albert, AlbertSSOTokenExchange
+
+def get_token() -> str:
+    # Acquire an ID token from your identity provider
+    ...
+
+auth = AlbertSSOTokenExchange(
+    base_url="https://mycompany.albertinvent.com",
+    subdomain="mycompany",
+    oidc_token_provider=get_token,
+)
+client = Albert(auth_manager=auth)
+```
+
+!!! warning "Use a callable, not a static token string"
+    Passing `lambda: "my-static-token"` works only while that OIDC token remains valid
+    (typically 60–90 minutes). Once it expires and the Albert access token needs renewal,
+    the exchange will fail. Always pass a callable that fetches a fresh token from your IdP.
+
+!!! warning "Token validity is your responsibility"
+    The SDK passes the token returned by `oidc_token_provider` directly to Albert. If your
+    callable returns an expired or invalid token, the exchange will fail with `401 Unauthorized`.
+    Ensure your token acquisition logic handles refresh appropriately.
+
+---
+
 ## 🔑 Client Credentials (Programmatic Access)
 
 This method implements the OAuth2 Client Credentials flow and is suitable for non-interactive usage, like backend services or automation scripts. It manages token acquisition and refresh automatically via the `AlbertClientCredentials` class.
diff --git a/docs/sso_exchange.md b/docs/sso_exchange.md
new file mode 100644
index 000000000..49595602c
--- /dev/null
+++ b/docs/sso_exchange.md
@@ -0,0 +1,3 @@
+# OIDC Token Exchange
+
+::: albert.AlbertSSOTokenExchange
diff --git a/mkdocs.yml b/mkdocs.yml
index d8101a2a5..943e1ab7c 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -131,6 +131,7 @@ nav:
       - Authentication:
           - Albert Client Credentials: credentials.md
           - Albert SSO Client: sso.md
+          - OIDC Token Exchange: sso_exchange.md
       - Collections:
           - Activities: collections/activities.md
           - Attachments: collections/attachments.md
diff --git a/src/albert/__init__.py b/src/albert/__init__.py
index 3e310bc75..53ad7ca1f 100644
--- a/src/albert/__init__.py
+++ b/src/albert/__init__.py
@@ -1,7 +1,14 @@
 from albert.client import Albert, AsyncAlbert
 from albert.core.auth.credentials import AlbertClientCredentials
 from albert.core.auth.sso import AlbertSSOClient
+from albert.core.auth.sso_exchange import AlbertSSOTokenExchange
 
-__all__ = ["Albert", "AsyncAlbert", "AlbertClientCredentials", "AlbertSSOClient"]
+__all__ = [
+    "Albert",
+    "AsyncAlbert",
+    "AlbertClientCredentials",
+    "AlbertSSOClient",
+    "AlbertSSOTokenExchange",
+]
 
 __version__ = "1.25.0"
diff --git a/src/albert/client.py b/src/albert/client.py
index 8a7911902..fb0566dc3 100644
--- a/src/albert/client.py
+++ b/src/albert/client.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import os
+from collections.abc import Callable
 
 from pydantic import SecretStr
 
@@ -56,6 +57,7 @@
 from albert.core.async_session import AsyncAlbertSession
 from albert.core.auth.credentials import AlbertClientCredentials
 from albert.core.auth.sso import AlbertSSOClient
+from albert.core.auth.sso_exchange import AlbertSSOTokenExchange
 from albert.core.session import AlbertSession
 from albert.utils._auth import default_albert_base_url
 
@@ -76,7 +78,7 @@ class Albert:
     token : str, optional
         A static token for authentication. If provided, it overrides any `auth_manager`.
         Defaults to the "ALBERT_TOKEN" environment variable.
-    auth_manager : AlbertClientCredentials | AlbertSSOClient, optional
+    auth_manager : AlbertClientCredentials | AlbertSSOClient | AlbertSSOTokenExchange, optional
         An authentication manager for OAuth2-based authentication flows.
         Ignored if `token` is provided.
     retries : int, optional
@@ -103,6 +105,7 @@ class Albert:
     - `from_token` — Create a client using a static token.
     - `from_sso` — Create a client using interactive browser-based SSO login.
     - `from_client_credentials` — Create a client using OAuth2 client credentials.
+    - `from_sso_exchange` — Create a client using server-to-server OIDC token exchange.
     """
 
     def __init__(
@@ -110,7 +113,10 @@ def __init__(
         *,
         base_url: str | None = None,
         token: str | None = None,
-        auth_manager: AlbertClientCredentials | AlbertSSOClient | None = None,
+        auth_manager: AlbertClientCredentials
+        | AlbertSSOClient
+        | AlbertSSOTokenExchange
+        | None = None,
         retries: int | None = None,
         session: AlbertSession | None = None,
     ):
@@ -169,6 +175,65 @@ def from_client_credentials(
         )
         return cls(auth_manager=creds, retries=retries)
 
+    @classmethod
+    def from_sso_exchange(
+        cls,
+        *,
+        base_url: str | None = None,
+        subdomain: str,
+        oidc_token_provider: Callable[[], str],
+        retries: int | None = None,
+    ) -> Albert:
+        """Create an Albert client using server-to-server OIDC token exchange.
+
+        Exchanges an OpenID Connect ID token for an Albert access token without
+        any browser interaction. Works with any OIDC-compliant identity provider
+        that emits the ``preferred_username`` claim.
+
+        Requires tenant-level OIDC configuration — the OpenID Connect ``aud`` claim
+        must be registered with Albert for the target tenant.
+
+        Parameters
+        ----------
+        base_url : str | None, optional
+            The base URL of the Albert API. Falls back to the ``ALBERT_BASE_URL``
+            environment variable or "https://app.albertinvent.com".
+        subdomain : str
+            The tenant subdomain (e.g. ``"mycompany"``).
+        oidc_token_provider : Callable[[], str]
+            A zero-argument callable that returns a fresh OIDC ID token on demand.
+            Called on the first request and on every token renewal.
+            A lambda returning a static string works for short-lived sessions.
+        retries : int | None, optional
+            Maximum number of retries for failed HTTP requests.
+
+        Returns
+        -------
+        Albert
+            A configured client that exchanges and refreshes tokens automatically.
+
+        Examples
+        --------
+        ```python
+        def get_oidc_token() -> str:
+            # Acquire an ID token from your identity provider
+            ...
+
+        client = Albert.from_sso_exchange(
+            base_url="https://mycompany.albertinvent.com",
+            subdomain="mycompany",
+            oidc_token_provider=get_oidc_token,
+        )
+        ```
+        """
+        resolved_base_url = base_url or default_albert_base_url()
+        auth = AlbertSSOTokenExchange(
+            base_url=resolved_base_url,
+            subdomain=subdomain,
+            oidc_token_provider=oidc_token_provider,
+        )
+        return cls(auth_manager=auth, retries=retries)
+
     @property
     def projects(self) -> ProjectCollection:
         return ProjectCollection(session=self.session)
@@ -374,7 +439,7 @@ class AsyncAlbert:
     token : str, optional
         A static JWT token. Ignored when ``auth_manager`` is provided.
         Defaults to the ``ALBERT_TOKEN`` environment variable.
-    auth_manager : AlbertClientCredentials | AlbertSSOClient, optional
+    auth_manager : AlbertClientCredentials | AlbertSSOClient | AlbertSSOTokenExchange, optional
         An authentication manager for OAuth2-based token refresh.
         Ignored if ``token`` is provided.
     session : AsyncAlbertSession, optional
@@ -419,7 +484,10 @@ def __init__(
         *,
         base_url: str | None = None,
         token: str | None = None,
-        auth_manager: AlbertClientCredentials | AlbertSSOClient | None = None,
+        auth_manager: AlbertClientCredentials
+        | AlbertSSOClient
+        | AlbertSSOTokenExchange
+        | None = None,
         session: AsyncAlbertSession | None = None,
     ):
         if session is not None:
@@ -495,6 +563,44 @@ def from_client_credentials(
         )
         return cls(auth_manager=creds)
 
+    @classmethod
+    def from_sso_exchange(
+        cls,
+        *,
+        base_url: str | None = None,
+        subdomain: str,
+        oidc_token_provider: Callable[[], str],
+    ) -> AsyncAlbert:
+        """
+        Create an AsyncAlbert client using server-to-server OIDC token exchange.
+
+        Works with any OIDC-compliant identity provider that emits the
+        ``preferred_username`` claim. Requires tenant-level OIDC configuration —
+        contact Albert support to enable.
+
+        Parameters
+        ----------
+        base_url : str | None, optional
+            The base URL of the Albert API. Falls back to the ``ALBERT_BASE_URL``
+            environment variable or "https://app.albertinvent.com".
+        subdomain : str
+            The tenant subdomain (e.g. ``"mycompany"``).
+        oidc_token_provider : Callable[[], str]
+            A zero-argument callable that returns a fresh OIDC ID token on demand.
+
+        Returns
+        -------
+        AsyncAlbert
+            A configured async client that exchanges and refreshes tokens automatically.
+        """
+        resolved_base_url = base_url or default_albert_base_url()
+        auth = AlbertSSOTokenExchange(
+            base_url=resolved_base_url,
+            subdomain=subdomain,
+            oidc_token_provider=oidc_token_provider,
+        )
+        return cls(auth_manager=auth)
+
     @property
     def chat_sessions(self) -> ChatSessionCollection:
         return ChatSessionCollection(session=self.session)
diff --git a/src/albert/core/auth/sso_exchange.py b/src/albert/core/auth/sso_exchange.py
new file mode 100644
index 000000000..bcf3855b2
--- /dev/null
+++ b/src/albert/core/auth/sso_exchange.py
@@ -0,0 +1,97 @@
+from __future__ import annotations
+
+from collections.abc import Callable
+from datetime import datetime, timedelta, timezone
+from urllib.parse import urljoin
+
+import requests
+from pydantic import Field
+
+from albert.core.auth._manager import AuthManager, OAuthTokenInfo
+from albert.core.base import BaseAlbertModel
+from albert.exceptions import AlbertAuthError, handle_http_errors
+from albert.utils._auth import default_albert_base_url
+
+
+class AlbertSSOTokenExchange(BaseAlbertModel, AuthManager):
+    """
+    Auth manager for server-to-server OIDC token exchange with the Albert API.
+
+    Exchanges an OpenID Connect (OIDC) ID token for an Albert access token without
+    any browser interaction. Suitable for custom applications that already authenticate
+    users via an OIDC-compliant identity provider.
+
+    The identity provider must emit the ``preferred_username`` claim in the ID token,
+    which Albert uses to look up the corresponding user. Most enterprise IdPs include
+    this claim by default; see the authentication guide for provider-specific notes.
+
+    Requires tenant-level OIDC configuration: the OpenID Connect ``aud`` claim
+    must be registered with Albert for the target tenant. Contact Albert support
+    to enable this feature.
+
+    Parameters
+    ----------
+    base_url : str
+        The base URL of the Albert API.
+    subdomain : str
+        The tenant subdomain (e.g. ``"mycompany"``).
+    oidc_token_provider : Callable[[], str]
+        A zero-argument callable that returns a fresh OIDC ID token on demand.
+        Called on the first request and on every token renewal.
+        A lambda returning a static string works for short-lived sessions.
+
+    Usage
+    -----
+    ```python
+    def get_oidc_token() -> str:
+        # Acquire an ID token from your identity provider
+        ...
+
+    auth = AlbertSSOTokenExchange(
+        base_url="https://mycompany.albertinvent.com",
+        subdomain="mycompany",
+        oidc_token_provider=get_oidc_token,
+    )
+    client = Albert(auth_manager=auth)
+    ```
+    """
+
+    base_url: str = Field(default_factory=default_albert_base_url)
+    subdomain: str
+    oidc_token_provider: Callable[[], str]
+
+    @property
+    def exchange_url(self) -> str:
+        return urljoin(self.base_url, "/api/v3/login/sso/exchange")
+
+    def _exchange(self) -> None:
+        with handle_http_errors():
+            response = requests.post(
+                self.exchange_url,
+                json={"jwt": self.oidc_token_provider(), "subdomain": self.subdomain},
+                timeout=30,
+            )
+            response.raise_for_status()
+            data = response.json()
+        access_token = data.get("jwt")
+        refresh_token = data.get("refreshtoken")
+        if not access_token or not refresh_token:
+            raise AlbertAuthError(
+                "SSO exchange failed: unexpected response from server. "
+                f"Expected 'jwt' and 'refreshtoken' fields, got: {list(data.keys())}"
+            )
+        expires_in = data.get("expires_in", 3300)  # fallback: 55 min until backend ships field
+        self._token_info = OAuthTokenInfo(
+            access_token=access_token,
+            refresh_token=refresh_token,
+            expires_in=expires_in,
+        )
+        self._refresh_time = (
+            datetime.now(timezone.utc) + timedelta(seconds=expires_in) - timedelta(minutes=1)
+        )
+
+    def get_access_token(self) -> str:
+        """Return a valid Albert access token, re-exchanging via the OIDC provider if needed."""
+        if self._requires_refresh():
+            self._exchange()
+        return self._token_info.access_token
