Skip to main content
Client Credentials authentication implements the standard OAuth2 client credentials grant flow. It’s a straightforward method that uses a client ID and secret to obtain access tokens.
This method is ideal for server-to-server communication in trusted environments where both client and server are under your control.

How It Works

The Client Credentials flow follows this process:
1

Generate Client Credentials

Create a service user in Zitadel and generate a client secret, which gives you a client ID and client secret.
2

SDK Initialization

Initialize the SDK with your client ID and secret.
3

Token Request

The SDK makes a POST request to Zitadel’s token endpoint with:
  • Grant type: client_credentials
  • Client ID and secret
  • Requested scopes
4

Token Response

Zitadel validates the credentials and returns an access token with expiration time.
5

Automatic Refresh

The SDK automatically requests a new token when the current one expires.

Setup

1. Create Service User and Generate Secret

In your Zitadel instance:
  1. Navigate to your project
  2. Create a new service user or select an existing one
  3. Generate a client secret
  4. Save the client ID and secret securely
The client secret is only shown once. Save it immediately in a secure location.

2. Store Credentials Securely

Never hardcode credentials in your source code. Use environment variables:
.env
ZITADEL_HOST=https://api.zitadel.example.com
ZITADEL_CLIENT_ID=234567890123456789@myproject
ZITADEL_CLIENT_SECRET=dEnT7H7UfG1yHUzMGxPDnXvALn0fJE2WgQRjxHC...
Add .env to your .gitignore:
.env
.env.*

3. Initialize the SDK

require 'zitadel-client'

client = Zitadel::Client::Zitadel.with_client_credentials(
  "https://api.zitadel.example.com",
  "234567890123456789@myproject",
  "dEnT7H7UfG1yHUzMGxPDnXvALn0fJE2WgQRjxHC..."
)

# Make API calls
response = client.users.add_human_user(
  Zitadel::Client::UserServiceAddHumanUserRequest.new(
    username: "john.doe",
    profile: Zitadel::Client::UserServiceSetHumanProfile.new(
      given_name: 'John',
      family_name: 'Doe'
    ),
    email: Zitadel::Client::UserServiceSetHumanEmail.new(
      email: "[email protected]"
    )
  )
)

Advanced Configuration

For advanced scenarios, use the builder pattern to customize OAuth parameters:
require 'zitadel-client'

# Build authenticator with custom scopes
authenticator = Zitadel::Client::Auth::ClientCredentialsAuthenticator
  .builder(
    "https://api.zitadel.example.com",
    ENV['ZITADEL_CLIENT_ID'],
    ENV['ZITADEL_CLIENT_SECRET']
  )
  .scopes(
    'openid',
    'urn:zitadel:iam:org:project:id:zitadel:aud',
    'urn:zitadel:iam:org:projects:roles'
  )
  .build

client = Zitadel::Client::Zitadel.new(authenticator)

Builder Methods

The ClientCredentialsAuthenticatorBuilder provides these configuration methods:
MethodDescriptionDefault
scopes(*scopes)OAuth scopes to request['openid', 'urn:zitadel:iam:org:project:id:zitadel:aud']

Implementation Details

Token Request

The SDK makes a token request like this:
POST /oauth/v2/token HTTP/1.1
Host: api.zitadel.example.com
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&
client_id=234567890123456789@myproject&
client_secret=dEnT7H7UfG1yHUzMGxPDnXvALn0fJE2WgQRjxHC...&
scope=openid+urn:zitadel:iam:org:project:id:zitadel:aud

Token Response

Zitadel responds with:
{
  "access_token": "MtjHodGy4zxKylDOhbRNvlBBewLOYvXz5...",
  "token_type": "Bearer",
  "expires_in": 43200
}
The SDK stores this token and automatically refreshes it before expiration.

Thread Safety

The ClientCredentialsAuthenticator is thread-safe:
  • Token refresh is protected by a mutex
  • Multiple threads can safely share the same client instance
  • Automatic refresh happens when the token expires
require 'zitadel-client'

# Safe to share across threads
client = Zitadel::Client::Zitadel.with_client_credentials(
  ENV['ZITADEL_HOST'],
  ENV['ZITADEL_CLIENT_ID'],
  ENV['ZITADEL_CLIENT_SECRET']
)

threads = 10.times.map do
  Thread.new do
    # All threads safely share the same client
    client.settings.get_general_settings
  end
end

threads.each(&:join)

Security Best Practices

Secret Management

Never commit client secrets to version control!
Use environment variables:
# ✅ Good - Uses environment variables
client = Zitadel::Client::Zitadel.with_client_credentials(
  ENV['ZITADEL_HOST'],
  ENV['ZITADEL_CLIENT_ID'],
  ENV['ZITADEL_CLIENT_SECRET']
)

# ❌ Bad - Hardcoded credentials
client = Zitadel::Client::Zitadel.with_client_credentials(
  "https://api.zitadel.example.com",
  "234567890123456789@myproject",
  "dEnT7H7UfG1yHUzMGxPDnXvALn0fJE2WgQRjxHC..."
)

Secret Rotation

Regularly rotate your client secrets:
1

Generate New Secret

Create a new client secret in Zitadel console.
2

Update Configuration

Update environment variables with the new secret.
3

Deploy Changes

Deploy updated configuration to your applications.
4

Deactivate Old Secret

After confirming the new secret works, deactivate the old one in Zitadel.

Scope Restriction

Request only the scopes your application needs:
# ✅ Good - Minimal scopes
authenticator = Zitadel::Client::Auth::ClientCredentialsAuthenticator
  .builder(host, client_id, client_secret)
  .scopes('openid', 'urn:zitadel:iam:org:project:id:zitadel:aud')
  .build

# ⚠️ Avoid - Unnecessary scopes increase risk
authenticator = Zitadel::Client::Auth::ClientCredentialsAuthenticator
  .builder(host, client_id, client_secret)
  .scopes('openid', 'profile', 'email', 'offline_access')
  .build

Multiple Environments

Use different credentials for each environment:
require 'zitadel-client'

case ENV['RAILS_ENV']
when 'production'
  client = Zitadel::Client::Zitadel.with_client_credentials(
    ENV['ZITADEL_PROD_HOST'],
    ENV['ZITADEL_PROD_CLIENT_ID'],
    ENV['ZITADEL_PROD_CLIENT_SECRET']
  )
when 'staging'
  client = Zitadel::Client::Zitadel.with_client_credentials(
    ENV['ZITADEL_STAGING_HOST'],
    ENV['ZITADEL_STAGING_CLIENT_ID'],
    ENV['ZITADEL_STAGING_CLIENT_SECRET']
  )
else
  client = Zitadel::Client::Zitadel.with_client_credentials(
    ENV['ZITADEL_DEV_HOST'],
    ENV['ZITADEL_DEV_CLIENT_ID'],
    ENV['ZITADEL_DEV_CLIENT_SECRET']
  )
end

Using Secret Management Services

For production deployments, use dedicated secret management:
require 'aws-sdk-secretsmanager'
require 'zitadel-client'
require 'json'

# Fetch from AWS Secrets Manager
secrets_client = Aws::SecretsManager::Client.new
response = secrets_client.get_secret_value(secret_id: 'zitadel/credentials')
creds = JSON.parse(response.secret_string)

client = Zitadel::Client::Zitadel.with_client_credentials(
  creds['host'],
  creds['client_id'],
  creds['client_secret']
)

Troubleshooting

Invalid Client Credentials

Error:
ZitadelError: Failed to refresh token: invalid_client
Solutions:
  1. Verify the client ID is correct
  2. Verify the client secret is correct and hasn’t expired
  3. Check that the service user exists in Zitadel
  4. Ensure the client secret hasn’t been deactivated

Unauthorized Client

Error:
ZitadelError: Failed to refresh token: unauthorized_client
Solutions:
  1. Verify the service user has the required permissions
  2. Check that the client credentials grant is enabled for the service user
  3. Ensure the service user belongs to the correct project

Invalid Scope

Error:
ZitadelError: Failed to refresh token: invalid_scope
Solutions:
  1. Verify the requested scopes are valid
  2. Check that the service user has access to the requested scopes
  3. Remove any custom scopes that aren’t configured in Zitadel

Connection Issues

Error:
ZitadelError: Failed to refresh token: Connection refused
Solutions:
  1. Verify the Zitadel host URL is correct
  2. Check network connectivity to Zitadel
  3. Ensure HTTPS is used (not HTTP)
  4. Verify firewall rules allow outbound HTTPS

Debug Token Issues

Enable debug logging to see detailed token exchange:
client = Zitadel::Client::Zitadel.with_client_credentials(
  ENV['ZITADEL_HOST'],
  ENV['ZITADEL_CLIENT_ID'],
  ENV['ZITADEL_CLIENT_SECRET']
) do |config|
  config.debug = true
end

# This will log:
# - Token requests and responses
# - Token refresh events
# - HTTP details

Comparison with Other Methods

FeatureClient CredentialsPrivate Key JWTPAT
Setup ComplexityLowMediumVery Low
SecurityHighHighestMedium
Secret ManagementClient secretPrivate key fileToken string
Token RefreshAutomaticAutomaticManual
Production ReadyYesYesNo
RotationGenerate new secretGenerate new keyGenerate new token
For maximum security in production, consider Private Key JWT instead.

Source Code Reference

The implementation can be found in:
  • Main entry point: lib/zitadel/client/zitadel.rb:106-112
  • Authenticator: lib/zitadel/client/auth/client_credentials_authenticator.rb
  • Builder: lib/zitadel/client/auth/client_credentials_authenticator.rb:42-61
  • Base OAuth: lib/zitadel/client/auth/o_auth_authenticator.rb

Next Steps

Private Key JWT

Higher security for production

Personal Access Tokens

Quick development setup

Additional Resources

Build docs developers (and LLMs) love