Skip to main content
Private Key JWT authentication is the most secure method for authenticating service users with Zitadel. It uses asymmetric cryptography where you sign a JWT assertion with your private key, eliminating the need to transmit secrets.
This is the recommended authentication method for production environments.

How It Works

The Private Key JWT flow works as follows:
1

Generate Service Account Key

Create a service user in Zitadel and download the JSON key file containing your private key, user ID, and key ID.
2

SDK Loads Key File

The SDK reads the JSON file and extracts the private key, key ID, and user ID.
3

JWT Creation

When authentication is needed, the SDK creates a JWT assertion with:
  • Issuer (iss): Your user ID
  • Subject (sub): Your user ID
  • Audience (aud): Zitadel host URL
  • Issued At (iat): Current timestamp
  • Expiration (exp): Current timestamp + lifetime (default 3600 seconds)
4

JWT Signing

The SDK signs the JWT with your RSA private key using RS256 algorithm and includes the key ID in the JWT header.
5

Token Exchange

The signed JWT is sent to Zitadel’s token endpoint to exchange for an OAuth access token.
6

Automatic Refresh

The SDK automatically refreshes the access token when it expires, creating a new JWT assertion each time.

Setup

1. Create a Service User in Zitadel

In your Zitadel instance:
  1. Navigate to your project
  2. Create a new service user or select an existing one
  3. Generate a new key
  4. Download the JSON key file

2. Secure Your Key File

The downloaded JSON file contains sensitive credentials:
service-account.json
{
  "type": "serviceaccount",
  "keyId": "123456789",
  "key": "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA...\n-----END RSA PRIVATE KEY-----\n",
  "userId": "234567890123456789"
}
Never commit this file to version control!Add it to .gitignore:
.gitignore
service-account.json
*.json
Set proper file permissions:
chmod 600 service-account.json

3. Initialize the SDK

require 'zitadel-client'

client = Zitadel::Client::Zitadel.with_private_key(
  "https://api.zitadel.example.com",
  "path/to/service-account.json"
)

# 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 use cases, you can use the builder pattern to customize JWT parameters:
require 'zitadel-client'
require 'json'

# Load and parse the key file
key_data = JSON.parse(File.read('service-account.json'))

# Build authenticator with custom settings
authenticator = Zitadel::Client::Auth::WebTokenAuthenticator
  .builder(
    "https://api.zitadel.example.com",
    key_data['userId'],
    key_data['key']
  )
  .key_identifier(key_data['keyId'])
  .token_lifetime_seconds(1800)  # 30 minutes instead of default 1 hour
  .scopes('openid', 'urn:zitadel:iam:org:project:id:zitadel:aud')
  .build

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

Builder Methods

The WebTokenAuthenticatorBuilder provides these configuration methods:
MethodDescriptionDefault
key_identifier(key_id)Sets the JWT kid headerFrom JSON file
token_lifetime_seconds(seconds)JWT expiration time3600 (1 hour)
scopes(*scopes)OAuth scopes to request['openid', 'urn:zitadel:iam:org:project:id:zitadel:aud']

Implementation Details

JWT Structure

The SDK generates JWTs with this structure:
Header
{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "123456789"
}
Payload
{
  "iss": "234567890123456789",
  "sub": "234567890123456789",
  "aud": "https://api.zitadel.example.com",
  "iat": 1709524800,
  "exp": 1709528400
}

Thread Safety

The WebTokenAuthenticator is thread-safe:
  • Token refresh is protected by a mutex
  • Multiple threads can 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_private_key(
  ENV['ZITADEL_HOST'],
  ENV['ZITADEL_KEY_FILE']
)

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)

Key Management

Key Rotation

Regularly rotate your service account keys:
1

Generate New Key

Create a new key in Zitadel console and download the JSON file.
2

Update Configuration

Update your application to use the new key file.
3

Deploy Changes

Deploy the updated configuration to your environments.
4

Deactivate Old Key

After confirming the new key works, deactivate the old key in Zitadel.

Multiple Environments

Use different service accounts for each environment:
require 'zitadel-client'

key_file = case ENV['RAILS_ENV']
           when 'production'
             'keys/production-service-account.json'
           when 'staging'
             'keys/staging-service-account.json'
           else
             'keys/development-service-account.json'
           end

client = Zitadel::Client::Zitadel.with_private_key(
  ENV['ZITADEL_HOST'],
  key_file
)

Storing Keys in Secret Managers

For cloud deployments, use secret management services:
require 'aws-sdk-secretsmanager'
require 'zitadel-client'
require 'tempfile'

# Fetch from AWS Secrets Manager
secrets_client = Aws::SecretsManager::Client.new
response = secrets_client.get_secret_value(secret_id: 'zitadel/service-account')
key_json = response.secret_string

# Write to temporary file
temp_file = Tempfile.new(['service-account', '.json'])
temp_file.write(key_json)
temp_file.close

client = Zitadel::Client::Zitadel.with_private_key(
  ENV['ZITADEL_HOST'],
  temp_file.path
)

temp_file.unlink  # Clean up after use

Troubleshooting

Invalid Key File Error

Error:
Unable to read JSON file at service-account.json: No such file or directory
Solution:
  • Verify the file path is correct
  • Use absolute paths or ensure relative paths are from the working directory
  • Check file permissions

JSON Parse Error

Error:
Invalid JSON in file at service-account.json: unexpected token
Solution:
  • Ensure the file contains valid JSON
  • Check for trailing commas or syntax errors
  • Re-download the file from Zitadel if corrupted

Missing Required Keys

Error:
Missing required keys 'userId', 'keyId' or 'key'
Solution:
  • Verify the JSON file contains all required fields:
    • userId: Service user identifier
    • keyId: Key identifier
    • key: RSA private key in PEM format
  • Re-download the key file from Zitadel

Token Refresh Failed

Error:
ZitadelError: Failed to refresh token: ...
Solutions:
  1. Check that the service user still exists in Zitadel
  2. Verify the key hasn’t been deactivated
  3. Ensure the Zitadel host URL is correct
  4. Check network connectivity to Zitadel
  5. Enable debug logging to see detailed error messages:
client = Zitadel::Client::Zitadel.with_private_key(
  host, key_file
) do |config|
  config.debug = true
end

Invalid Signature

Error:
Invalid signature
Solutions:
  • The private key may be corrupted
  • The key ID (kid) doesn’t match the key in Zitadel
  • Re-download the key file from Zitadel

Source Code Reference

The implementation can be found in:
  • Main entry point: lib/zitadel/client/zitadel.rb:120-122
  • Authenticator: lib/zitadel/client/auth/web_token_authenticator.rb
  • Builder: lib/zitadel/client/auth/web_token_authenticator.rb:125-164

Next Steps

Client Credentials

Simpler OAuth2 authentication

Personal Access Tokens

Quick development setup

Additional Resources

Build docs developers (and LLMs) love