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:
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.
SDK Loads Key File
The SDK reads the JSON file and extracts the private key, key ID, and user ID.
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)
JWT Signing
The SDK signs the JWT with your RSA private key using RS256 algorithm and includes the key ID in the JWT header.
Token Exchange
The signed JWT is sent to Zitadel’s token endpoint to exchange for an OAuth access token.
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:
Navigate to your project
Create a new service user or select an existing one
Generate a new key
Download the JSON key file
2. Secure Your Key File
The downloaded JSON file contains sensitive credentials:
{
"type" : "serviceaccount" ,
"keyId" : "123456789" ,
"key" : "-----BEGIN RSA PRIVATE KEY----- \n MIIEowIBAAKCAQEA... \n -----END RSA PRIVATE KEY----- \n " ,
"userId" : "234567890123456789"
}
Never commit this file to version control! Add it to .gitignore: service-account.json
* .json
Set proper file permissions:
chmod 600 service-account.json
3. Initialize the SDK
Basic Usage
Using Environment Variables
With Debug Logging
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:
Method Description Default key_identifier(key_id)Sets the JWT kid header From JSON file token_lifetime_seconds(seconds)JWT expiration time 3600 (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:
{
"alg" : "RS256" ,
"typ" : "JWT" ,
"kid" : "123456789"
}
{
"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:
Generate New Key
Create a new key in Zitadel console and download the JSON file.
Update Configuration
Update your application to use the new key file.
Deploy Changes
Deploy the updated configuration to your environments.
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:
AWS Secrets Manager
Google Secret Manager
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:
Check that the service user still exists in Zitadel
Verify the key hasn’t been deactivated
Ensure the Zitadel host URL is correct
Check network connectivity to Zitadel
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:
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