Temporal JWT token authentication

AUTHORS
Adam Quan
PUBLISHED
Oct 27, 2026
DURATION
13 MIN
  • Python
  • Code Samples

Production Temporal clusters always need to be secured. Temporal has a pluggable security architecture that allows users to implement single sign-on (SSO) authentication using security tokens. Temporal supports authentication using JSON Web Tokens (JWTs) and provides a default JWT authentication implementation.

This blog walks through configuring JWT authentication for self-hosted Temporal using Keycloak, a popular open-source identity provider. You can also use other identity providers, such as Google or Okta.

This is the first blog in my three-part series on Temporal security. The next two blogs will cover securing Temporal cross-cluster replication with mTLS and JWT Tokens.

Architecture#

Here is the high-level architecture for user authentication and application access:

We need authentication for both people using the Temporal UI and programmatic clients, such as Temporal Workers and SDK applications.

  1. For programmatic Clients (Temporal Workers & SDK Apps), we will use the Client Secret (Client Credentials Flow). Temporal Workers and backend applications are considered "confidential clients" because they run in secure, server-side environments where secrets can be safely hidden. They do not represent a single human user.

In this case, we can configure a client in Keycloak with Service Accounts Enabled and Client Authentication turned on. The Worker authenticates using its client_id and client_secret via the OAuth 2.0 Client Credentials grant. Keycloak returns a machine-to-machine JWT token. This ensures automation scripts and Workers can run autonomously without requiring an interactive human login or risking an expired user password blocking the background pipelines.

  1. For human users (Temporal Web UI & CLI), we will use a username and password (Authorization Code Flow with PKCE). When engineers or administrators need to access the Temporal Web UI or execute commands using the Temporal CLI, they are authenticating as individual users. From the Web UI, they are redirected to a Keycloak login screen. Keycloak generates a token tied to that user's identity and roles. This allows Temporal's ClaimMapper and Authorizer to evaluate individual user permissions (e.g., restricting a junior developer from terminating a workflow while allowing an admin to do so).

We will implement both and see them in action. You can access all the configurations and code from this repository.

Note that, for simplicity, we are only showcasing JWT token authentication in this blog, without Transport Level Security (TLS). Temporal's built-in token authentication mechanisms operate at the gRPC application layer via metadata (HTTP/2 headers), allowing you to extract and map claims independently of the underlying transport security. However, running JWT without TLS means your tokens will be transmitted in plain text across the network, making them vulnerable to interception.

Temporal security architecture#

The following diagram from the official documentation illustrates Temporal’s high-level security architecture, as it relates to authentication and authorization.

Two key components in this architecture are:

  • ClaimMapper: Translates the caller identity extracted from the transport layer or tokens (e.g. JWT tokens) into standard role claims and permissions. Temporal offers a default JWT ClaimMapper.
  • Authorizer: Evaluates the mapped role claims against requested API actions to enforce access control decisions, allowing or denying requests.

Since we are focusing on authentication in the blog, we only need to be concerned with ClaimMapper.

  • The default claim name is permissions
  • The permissions claim contains an array of strings in the format namespace:permission. The namespace is either a namespace name or temporal-system, which indicates the entire cluster. The permission is one of Temporal’s roles: read, write, worker, admin.

Here is an example payload for the default JWT ClaimMapper. We’ll configure Keycloak to generate this claim.

{
   "permissions":[
      "temporal-system:admin",
      "namespace1:write"
   ],
   …
}

Keycloak configuration#

Now that we’ve covered how Temporal’s ClaimMapper works, we can configure Keycloak for JWT authentication. We need to create the following entities in Keycloak:

  1. A realm that isolates our application from other realms. It will contain all other entities.
  2. A client that represents our Temporal application and authenticates with Keycloak to obtain tokens.
  3. A role that gives our Temporal user the right access. We will need cluster-level admin access.
  4. A protocol mapper that maps user attributes into identity (ID) tokens and access tokens.
  5. A user that represents the Temporal user who will authenticate to the Temporal Web UI or Temporal server.

Names in green in the diagram below represent all the entities we will be creating. The ultimate goal is to assign the temporal-system:admin role to both the client (service account) temporal-app and the user temporal-user and encode that in the JWT token!

The script setup-keycloak.sh creates all the entities automatically:

docker compose up keycloak -d 
./scripts/setup-keycloak.sh

Here are the detailed steps to create them manually from the Keycloak UI:

  1. Start the Keycloak container with the following command:
docker compose up keycloak -d
  1. Log into the Keycloak UI at http://localhost:9080 with admin/admin.
  2. Create a realm named temporal from Manage realms.
  3. Create a client named temporal-app from Clients. Make sure:
  • Client type is OpenID Connect
  • Client authentication is On. This makes the client a confidential client and generates a client secret.
  • Direct access grants and Service account roles are checked. This enables the Client Credential Grant.

Provide the Temporal UI URL pattern http://localhost:8080/* as the Validate redirect URIs. This tells the browser where to redirect after a successful user log in. 5. Copy and save the Client Secret from the Credentials tab of the client. We will need the secret for authentication.

  1. Create a new role temporal-system:admin from the Roles tab. This role will provide our user and client with system-wide admin permissions.
  2. To support programmatic clients through the client authentication flow, we need to assign the temporal-system:admin role to the client's internal service account user. Because we enabled Service account roles when creating this confidential client, Keycloak automatically created a hidden "Service Account User" tied to it. From the Service account roles tab, click on Assign role → Client roles. Search and select the temporal-system:admin role. Assign it to the service account.

Assign Client roles to temporal-app

  1. Now, let’s configure the Protocol Mapper to output client roles into the JWT token. By default, Keycloak places client roles inside a resource_access nested JSON object. However, as we pointed out before, Temporal expects permissions to live inside a flat array claim named permissions. We need a mapper to extract this client role and place it there.

Create the mapper by clicking on the temporal-app-dedicated client scope from the Client scopes tab, then Configure a new mapper and select User Client Role. The client scope temporal-app-dedicated is a special, application-specific client scope that is automatically created for and uniquely tied to our client. It primarily acts as a container for two things: dedicated Protocol Mappers and dedicated Scope Mappings.

Client details tab

User Client Role tab

Name the mapper temporal-permissions-mapper and select temporal-app as the Client ID. Set Token Claim Name to permissions, Multivalued to On, and Claim JSON Type to String. These settings generate the permissions claim as the array Temporal expects:

"permissions":[
      "temporal-system:admin",
   ]

Mapper details tab

  1. To support human users, we also need to create a Keycloak user and assign the client role to the user. Create a user called temporal-user, and set the user password from the Credentials tab. Make sure Temporary is Off. This prevents Keycloak from requiring the user to reset their password on their first login.

  2. Similar to the client service account, let’s perform the role mapping for the user! From the Role mapping tab, click on Assign role → Client roles. Search and pick the temporal-system:admin role we created. The user temporal-user is now assigned the temporal-system:admin role!

Role mapping sub tab under temporal-user

Client roles being assigned to temporal-user

That’s it! With the realm, client, user, and role mapping created, both our client and user should have the right permission to access our Temporal cluster. We can test the configuration by generating a token using the client credentials flow, with this command:

curl -X POST http://localhost:9080/realms/temporal/protocol/openid-connect/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials" \
  -d "client_id=temporal-app" \
  -d "client_secret=YOUR_SECRET"

The output will be something like this, with the JWT token:

{"access_token":"eyJhbGci…","expires_in":300,"refresh_expires_in":0,"token_type":"Bearer","not-before-policy":0,"scope":"email profile"}

If you are interested in seeing what’s encoded inside the token, you can use https://www.jwt.io/ to decode the token. Part of the decoded JSON document will contain permissions for the client temporal-app:

Permissions for the client temporal-app

Similarly, you can also generate a token using the authorization code flow with a user name and password:

curl -X POST http://localhost:9080/realms/temporal/protocol/openid-connect/token \
     -H "Content-Type: application/x-www-form-urlencoded" \
     -d "client_id=temporal-app" \
     -d "client_secret=YOUR_SECRET" \
     -d "username=temporal-user" \
     -d "password=temporal" \
     -d "grant_type=password"

Part of the decoded token will contain permissions for the user temporal-user.

Permissions for the user temporal-user

Temporal configuration#

To secure your Temporal cluster access, we need to configure both the frontend service and the Temporal Web UI accordingly. Users interact with the Web UI, while Temporal Workers, client applications, and the Web UI talk to the frontend service.

Frontend service#

When you configure access control on a self-hosted Temporal cluster, authorization happens in a two-step process:

  1. ClaimMapper: First, an identity token (like a JWT) or TLS certificate is passed via gRPC metadata. The ClaimMapper extracts this data and translates it into standard Temporal role claims.
  2. Authorizer: The Authorizer then evaluates those claims against the specific API request (e.g., trying to read history or start a workflow). It outputs a decision to allow or deny the action.

To secure the frontend service access with JWT tokens, we can configure the following environment variables for the container:

  • TEMPORAL_AUTH_AUTHORIZER - tells Temporal the role-validation logic to use.
  • TEMPORAL_AUTH_CLAIM_MAPPER tells the Temporal server the ClaimMapper plugin to use for decoding and parsing JWT tokens.
  • TEMPORAL_JWT_KEY_SOURCE1 tells the Temporal server where to fetch public keys to verify JWT tokens sent by clients, Workers, or the web UI.

The demo repository uses the following values to enable Temporal’s default authorizer and JWT ClaimMapper:

TEMPORAL_AUTH_AUTHORIZER=default
TEMPORAL_AUTH_CLAIM_MAPPER=default
TEMPORAL_JWT_KEY_SOURCE1=http://keycloak:9080/realms/temporal/protocol/openid-connect/certs

You can also configure these in the server configuration file config.yaml by explicitly enabling Temporal's default authorizer and default ClaimMapper and supplying a JWKS endpoint or public key source to verify incoming tokens.

# Configure the authorization plugin to intercept gRPC requests
global:
  authorization:
    authorizer: "default"      # Enables Temporal's built-in role-based authorizer
    claimMapper: "default"     # Extracts roles/permissions from verified tokens

    # Provide the keys needed to verify incoming JWT signatures
    jwtKeyProvider:
      keySourceURIs:
	 # Identity Provider URI
        - "http://keycloak:9080/realms/temporal/protocol/openid-connect/certs" 
      refreshInterval: 1m

When enabling authentication, it’s easy to overlook the internal-frontend service. The internal frontend is a split instance of Temporal's Frontend Service designed to handle unauthenticated communication for internal cluster components and system workers. We need to bypass authentication for internal components. The internal frontend service’s key functions and purpose include:

  • Isolation from public traffic: It separates internal cluster traffic, like internal Temporal system workflows and background tasks, from external user or SDK client traffic.
  • Bypassing authentication: It typically runs without external authentication constraints like JWT, allowing internal system workers to communicate freely without triggering "unauthorized" errors.

To enable the internal frontend service, we can set the following environment variables for the Temporal server. We essentially are running one Temporal instance for all services, including the internal-frontend service.

TEMPORAL_SERVICES=frontend,matching,worker,history,internal-frontend

Temporal UI#

Similarly, for the Temporal UI, we can set the following environment variables for the container:

  • TEMPORAL_AUTH_ENABLED=true turns on authentication.
  • TEMPORAL_AUTH_PROVIDER_URL provides the IDP (Keycloak) discovery URL.
  • TEMPORAL_AUTH_CLIENT_ID identifies the application to the identity provider (Keycloak) during the OAuth authentication flow.
  • TEMPORAL_AUTH_CLIENT_SECRET provides the secret key for your OAuth client so Temporal can authenticate with your identity provider (Keycloak).
  • TEMPORAL_AUTH_CALLBACK_URL tells the IDP (Keycloak) where to send the JWT token.
  • TEMPORAL_AUTH_SCOPES specifies a comma-separated list of OIDC scopes sent to your identity provider (Keycloak) so the UI server can verify identity and pull user profile/email claims. Default Value: "openid,email,profile"

In the demo repository, they are set as:

TEMPORAL_AUTH_ENABLED=true
TEMPORAL_AUTH_PROVIDER_URL=http://keycloak:9080/realms/temporal
TEMPORAL_AUTH_CLIENT_ID=temporal-app
TEMPORAL_AUTH_CLIENT_SECRET=xxxxxxxxxx
TEMPORAL_AUTH_CALLBACK_URL=http://localhost:8080/auth/sso/callback
TEMPORAL_AUTH_SCOPES=openid,profile,email

Temporal CLI access test#

Before building an example client application, let’s use the Temporal CLI to confirm that JWT authentication is working. Make sure you start the Temporal cluster with the following command:

docker compose up -d

First, try to list namespaces in the cluster with the following command:

temporal operator namespace list

You will get the following error, because you are not providing a JWT token:

Error: failed reaching server: Request unauthorized.

Let’s try it again with the token we generated in the previous section. Temporal passes authorization data via the authorization gRPC metadata header, formatted as a Bearer token. We can pass that information in the environment variable TEMPORAL_GRPC_META_AUTHORIZATION. Use the token you generated from the curl command in the previous section.

export TEMPORAL_GRPC_META_AUTHORIZATION="Bearer YOUR_TOKEN"

Run the same command again. You should now see information about all the namespaces in your cluster. Otherwise, something went wrong with your setup.

The repository’s create-namespace.sh script handles these steps. It creates the default namespace default. To do so, it needs to generate a JWT token and pass it to the Temporal server through the CLI.

Temporal UI test#

Open the Temporal UI at http://localhost:8080. You should see a login screen with a Continue to SSO button.

Login screen with a Continue to SSO button

Log in with the user name temporal-user and password temporal. You should be redirected to the Temporal UI after a successful login. Single sign-on is now working!

Login screen with username and password fields filled

SDK client example#

Great, now let’s create a client application to access the cluster with a JWT. For simplicity, we will use the Python SDK.

To authenticate to a self-hosted Temporal cluster using a JWT token, you must supply the token inside the rpc_metadata dictionary when connecting your client. Temporal expects the standard authorization header containing a bearer token.

client = await Client.connect(
        "localhost:7233",
        namespace="default",
        rpc_metadata={"authorization": f"Bearer {initial_jwt}"}
    )

Because JWT tokens are typically short-lived, a static header will eventually cause authentication failures on long-running Workers. The Temporal Python SDK lets you update the client’s rpc_metadata without disrupting ongoing gRPC connections or restarting Workers. The example below refreshes the token in a background loop shortly before it expires. This lets Workers reuse a token across requests and avoids sending a token request to Keycloak for every API call.

async def token_refresh_loop(client: Client):
    """
    Background worker loop that manages dynamic JWT rotation.
    Reads token lifetime returned by Keycloak and refreshes proactively.
    """
    while True:
        try:
            token_data = await fetch_keycloak_token_info()
            jwt_token = token_data["access_token"]

            # Mutate the client rpc metadata seamlessly
            client.rpc_metadata = {"authorization": f"Bearer {jwt_token}"}
            print("Successfully refreshed JWT from Keycloak.")

            # Use Keycloak's 'expires_in' field to determine sleep cycle
            # Buffer by 30 seconds to ensure the token doesn't expire mid-flight
            expires_in = token_data.get("expires_in", 300)
            sleep_duration = max(10, expires_in - 30) 

        except Exception as e:
            print(f"Failed to fetch token from Keycloak: {e}")
            # If Keycloak is transiently down, try again shortly
            sleep_duration = 15 

        await asyncio.sleep(sleep_duration)

The complete code example is also in the repository.

Temporal Cloud

Ready to see for yourself?

Sign up for Temporal Cloud today and get $150 in free credits.

Build invincible applications

It sounds like magic, we promise it's not.