Table of Contents
- Introduction
- Keycloak configuration
- Implementation
- Implementation: Advanced Authorization
- Custom Server Orchestration
- Verification & Testing
- Conclusions
Introduction
Temporal is an open-source “Durable Execution” platform that allows developers to write code that is entirely resilient to process crashes, network interruptions, and long wait times. In the Temporal world, if your server reboots in the middle of a function, your code simply resumes exactly where it left off.
However, a critical question arises: How do we secure the cluster? By default, Temporal is open. Anyone with access to the server can start workflows, terminate jobs, or read sensitive data. While Temporal provides a standard way to plug in an Identity Provider (like Keycloak or Okta), you might have an existing security schema, legacy JWT structures, or complex business logic that determines who can access which namespace.
In this article, we will go under the hood of Temporal’s security layer. I will show you how to move beyond basic configurations by writing custom Go logic to hijack the authorization flow, allowing you to map any identity into Temporal-specific roles.
Temporal’s security model is designed to be pluggable. While Temporal provides a default JWT implementation, in this article, I will present how to implement a Custom Authorizer and Custom ClaimMapper to translate external identities into Temporal-specific roles.
The example shown in this article is for presentation only. To present how custom authorization works. It’s not production-ready, and it presents a very naive approach to authorization. The full source code with documentation on how to run the code discussed in this article can be found here: https://github.com/piotrmucha/temporal-custom-auth
How Custom Authorization Works
In this specific implementation, we bypass Temporal’s standard permissions claim requirement in favor of a custom field.
- Authentication: Users authenticate via Keycloak and receive a JWT containing a custom array field named custom.
- Claim Mapping: The ClaimMapper intercepts the token, extracts the custom field, and maps specific strings to internal Temporal roles.
- Authorization: The Authorizer uses these mapped roles to decide if a gRPC API call (from the CLI, UI, or SDK) should be allowed or denied based on the target namespace and action.
The Expected JWT Payload
Unlike the standard Temporal setup, this custom logic expects your token to include a custom array. This array defines the user’s permissions across the cluster:
{
"sub": "dd6d098e-c9d6...",
"custom": [
"admin",
"only-default-read"
]
}
Mapping Scopes to Roles
This implementation recognizes two primary custom scopes that grant access to the system:
- admin (Global System Access):
- Maps directly to temporal-system:admin.
- Grants full administrative control over the entire Temporal cluster, including all namespaces and system-level operations.
- only-default-read (Namespace Restricted):
- Maps to default:read.
- Grants read-only access strictly to the default namespace. Users with this scope can view workflow histories but cannot start, signal, or modify any executions.
Keycloak configuration
I already described how to configure Keycloak in this article: Implementing authorization in temporal server But if you want to implement custom authorization logic, you have to make two changes. The primary goal is to move from the default permissions claim to a custom claim and change the role names.
Update Protocol Mapper
Our custom Go code specifically looks for an array field named custom.
- Navigate to Clients -> temporal-access -> Mappers tab.
- Select the mapper you previously created (likely named permissions-mapper).
- Change the Token Claim Name from permissions to custom.
- Ensure Multivalued is still set to ON.
- Click Save.
Change Client Roles
The custom authorizer code maps specific strings like admin and only-default-read directly to Temporal roles. We need to adjust our client-level roles to provide these exact strings.
- Go to the Roles tab of the temporal-access client.
- Delete the previous role temporal-system:admin if it exists.
- Click Add Role and create the following two roles:
- admin: This grants global System Admin access.
- only-default-read: This grants Reader access to the default namespace.
- Assign these new roles to your temporal-user under the Role Mappings tab.
Verify the JWT Structure
After these changes, your JWT payload should no longer contain a permissions field. Instead, it must include the custom array:
{
"sub": "dd6d098e-c9d6...",
"custom": [
"admin",
"only-default-read"
]
}
Implementation
To move beyond default JWT parsing, you must implement two core Go interfaces provided by the Temporal server: ClaimMapper and Authorizer. This allows you to handle the custom field instead of the standard permissions claim.
The Custom ClaimMapper
The ClaimMapper is responsible for translating the “raw” identity data from the JWT into internal Temporal Claims.
In our implementation, the GetClaims method extracts the custom array from the token and maps our simplified strings to official Temporal roles.
func (c *CustomClaimMapper) GetClaims(authInfo *authorization.AuthInfo) (*authorization.Claims, error) {
claims := &authorization.Claims{
System: authorization.RoleUndefined,
Namespaces: make(map[string]authorization.Role),
}
// Extract our 'custom' field from the JWT
customPermissions, subject, err := parseJWTCustomField(authInfo.AuthToken)
if err != nil {
return claims, nil
}
claims.Subject = subject
for _, perm := range customPermissions {
switch perm {
case "admin":
// Maps our simple string to the System Admin role
claims.System = authorization.RoleAdmin
case "only-default-read":
// Restricts access to just the 'default' namespace
claims.Namespaces["default"] = authorization.RoleReader
}
}
return claims, nil
}
The Custom Authorizer
While the mapper grants roles, the Authorizer enforces them for every incoming API call. It compares the user’s claims against the CallTarget (the specific API and Namespace being requested).
Our Authorize method uses Temporal’s built-in metadata to determine if a user’s role (e.g., Reader) meets the required access level for an API (e.g., ReadOnly).
func (a *CustomAuthorizer) Authorize(
ctx context.Context,
claims *authorization.Claims,
target *authorization.CallTarget,
) (authorization.Result, error) {
// 1. Always allow health checks
if authorization.IsHealthCheckAPI(target.APIName) {
return authorization.Result{Decision: authorization.DecisionAllow}, nil
}
// 2. Global System Admins bypass all other checks
if claims != nil && claims.System == authorization.RoleAdmin {
return authorization.Result{Decision: authorization.DecisionAllow}, nil
}
// 3. Check specific role requirements for the target API
metadata := api.GetMethodMetadata(target.APIName)
requiredRole := getRequiredRole(metadata.Access)
// logic to compare user's namespace role vs requiredRole...
}
Integrating with the Temporal Server
Finally, these components are injected into the server during initialization using the WithClaimMapper and WithAuthorizer server options.
server, err := temporal.NewServer(
temporal.ForServices(services),
temporal.WithConfig(cfg),
// Inject our custom logic here
temporal.WithClaimMapper(func(_ *config.Config) authorization.ClaimMapper {
return NewCustomClaimMapper()
}),
temporal.WithAuthorizer(NewCustomAuthorizer()),
temporal.InterruptOn(temporal.InterruptCh()),
)
Important Security Note: Signature Validation
In this presentation logic, I extracted data from the JWT by decoding the payload without validating the cryptographic signature. Skipping signature validation is for simplicity and testing only. In a production environment, you must implement signature validation using public keys (JWKS). Failing to do so allows anyone to forge a token with an “admin” claim and gain full control over your cluster.
Implementation: Advanced Authorization
To further refine your security model, you can implement Workflow Type-based authorization. While standard Temporal roles apply to the entire namespace, a custom Authorizer can inspect the gRPC request payload to identify the specific workflow being targeted.
1. Extracting the Workflow Type
The CallTarget.Request field contains the deserialized request object. We use a helper function to perform type assertions on different request types (like StartWorkflowExecutionRequest) to retrieve the internal WorkflowType name.
func extractWorkflowType(target *authorization.CallTarget) string {
if target.Request == nil {
return ""
}
switch req := target.Request.(type) {
// Standard start request
case *workflowservice.StartWorkflowExecutionRequest:
return req.GetWorkflowType().GetName()
// Signal with start
case *workflowservice.SignalWithStartWorkflowExecutionRequest:
return req.GetWorkflowType().GetName()
// Multi-operation requests
case *workflowservice.ExecuteMultiOperationRequest:
for _, op := range req.GetOperations() {
if startReq := op.GetStartWorkflow(); startReq != nil {
return startReq.GetWorkflowType().GetName()
}
}
}
return ""
}
Implementing Logic in the Authorize Method
Once the workflow type is extracted, it can be used for granular decision-making. For example, you can log the type or use it to block specific high-risk workflows for non-admin users.
In the Authorize function, we integrate this as follows:
func (a *CustomAuthorizer) Authorize(
ctx context.Context,
claims *authorization.Claims,
target *authorization.CallTarget,
) (authorization.Result, error) {
// Extract WorkflowType based on the API being called
workflowType := extractWorkflowType(target)
if workflowType != "" {
fmt.Printf("[CustomAuth] WorkflowType: %s\n", workflowType)
}
// ... (logic for health checks and system admin bypass) ...
// You can now use workflowType for custom business rules
// Example: If workflowType is "CriticalUpdate", ensure the user has Admin roles
}
Custom Server Orchestration
When integrating custom Go logic into a Dockerized environment, you must navigate the startup sequence of the standard temporalio/auto-setup image. Our implementation requires two specific adjustments: overriding the startup script and parsing service environment variables.
Overriding the Startup Sequence
The temporalio/auto-setup image is designed to automate database schema initialization before starting the server. Its entry point performs a critical sequence:
- Schema Setup: Runs auto-setup.sh to initialize databases and register the default namespace.
- Server Startup: Executes /etc/temporal/start-temporal.sh, which by default calls the standard temporal-server binary.
Because our custom server is a standalone binary containing the ClaimMapper and Authorizer, we cannot use the standard temporal-server command. In the Dockerfile, we perform an override:
RUN echo '#!/bin/bash' > /etc/temporal/start-temporal.sh && \
echo 'exec /usr/local/bin/temporal-custom' >> /etc/temporal/start-temporal.sh && \
chmod +x /etc/temporal/start-temporal.sh
Manual Service Parsing
In a standard deployment, the SERVICES environment variable (e.g., frontend:history:matching) is parsed by a shell script that passes –service flags to the binary https://github.com/temporalio/docker-builds/blob/main/docker/start-temporal.sh . Since we are running our Go main() directly, we must handle this logic ourselves.
The getServices() function in our main.go replicates this behavior:
func getServices() []string {
env := os.Getenv("SERVICES")
if env == "" {
return temporal.DefaultServices
}
env = strings.ReplaceAll(env, ":", ",")
var result []string
for _, s := range strings.Split(env, ",") {
s = strings.TrimSpace(s)
if s != "" {
result = append(result, s)
}
}
return result
}
Verification & Testing
To confirm that your custom ClaimMapper and Authorizer are working correctly, you should perform a manual test using the Temporal CLI (tctl or temporal). This test verifies that the server successfully intercepts your token, extracts the custom claim, and grants access based on your Go logic.
Attempt Access Without a Token
First, try to communicate with your Temporal cluster without any authentication. This ensures that your authorization layer is actually active and rejecting anonymous requests.
# Set the server address (standard local port)
export TEMPORAL_ADDRESS="localhost:7233"
# Attempt to list namespaces
temporal operator namespace list
Expected Result: The server should reject the request with a PermissionDenied error.
Error: Error when list namespaces info
Error Details: rpc error: code = PermissionDenied desc = Request unauthorized.
Attempt Access With Global Admin Token
Now, use the token you generated via the REST API that contains the "custom": ["admin"] claim. Export it as an environment variable that the Temporal CLI uses for gRPC metadata.
# Export your admin token
export TEMPORAL_CLI_AUTH="Bearer eyJhbGciOiJSUzI1NiIsInR5cCIg..."
# Execute the command again
temporal operator namespace list
Expected Result: The Go code will map this to SystemAdmin, and the command will succeed.
Name: default
Id: 2d3d4e5f-6a7b-8c9d-0e1f-2a3b4c5d6e7f
Status: REGISTERED
...
Attempt Access With “Read-Only” Token
To test the granularity of your custom logic, use a token that only contains the "custom": ["only-default-read"] claim. This role is designed to allow viewing workflows in the default namespace but should prevent administrative or write actions.
# Export your read-only token
export TEMPORAL_CLI_AUTH="Bearer eyJhbGciOiJSUzI1NiIs..."
# Try to list workflows in the default namespace (Should Work)
temporal workflow list --namespace default
# Try to terminate a workflow in the default namespace (Should Fail)
temporal workflow terminate -n default -w "any-id"
Expected Result:
- List Workflows: Succeeds because the token maps to
RoleReaderfor thedefaultnamespace. - Terminate Workflow: Fails with
PermissionDeniedbecauseterminaterequiresRoleWriterorRoleAdminaccess, which our custom scope does not provide
Conclusions
Maintenance
Implementing custom authorization requires an understanding of Go and Temporal’s internal architecture.
- Complexity: As seen in our main.go, we wrote a substantial amount of boilerplate code just to handle basic mapping and routing, and we even skipped JWT signature validation, which is a mandatory, non-trivial requirement for production security.
- API Volatility: During the creation of this article, I noticed that online examples are frequently outdated. Temporal’s internal APIs (such as those in the common/authorization package) evolve between versions. If you go this route, you are responsible for auditing and updating your custom binary every time you upgrade the Temporal server.
“Hacked” Integration
Because we are running a custom binary within the official auto-setup Docker image, we had to resort to several “hacks”:
- Manual Service Parsing: We had to manually implement getServices() to replicate the string-parsing logic (frontend:history…) that Temporal’s official shell scripts handle for us.
- Startup Overrides: Overwriting /etc/temporal/start-temporal.sh is a fragile way to hijack the entrypoint. While it works, it creates a maintenance dependency on the internal file structure of the official Temporal Docker images.