Skip to main content
Personal Access Tokens (PATs) provide a simple authentication method using a pre-generated static token. This approach is ideal for development, testing, and scripts where you need quick setup without the complexity of OAuth flows.
PATs are recommended for development and testing only. For production environments, use Private Key JWT or Client Credentials.

How It Works

The Personal Access Token flow is straightforward:
1

Generate Token

Create a Personal Access Token in the Zitadel console. This is a pre-generated bearer token.
2

SDK Initialization

Initialize the SDK with your token and host URL.
3

Direct API Access

The SDK includes the token in the Authorization header of every API request as a bearer token.
Unlike OAuth-based methods, PATs:
  • Don’t require token exchange
  • Don’t automatically refresh
  • Are sent directly with each request
  • Have a fixed expiration time

Setup

1. Generate a Personal Access Token

In your Zitadel instance:
  1. Navigate to your user settings or service user
  2. Go to Personal Access Tokens
  3. Click “New”
  4. Set an expiration date
  5. Copy the generated token immediately
The token is only displayed once. Save it securely before closing the dialog.

2. Store the Token Securely

Never commit tokens to version control. Use environment variables:
.env
ZITADEL_HOST=https://api.zitadel.example.com
ZITADEL_PAT=dEnT7H7UfG1yHUzMGxPDnXvALn0fJE2WgQRjxHC...
Add .env to .gitignore:
.env
.env.*

3. Initialize the SDK

require 'zitadel-client'

client = Zitadel::Client::Zitadel.with_access_token(
  "https://api.zitadel.example.com",
  "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]"
    )
  )
)

Implementation Details

Authentication Header

The SDK adds the token to every request:
GET /management/v1/users HTTP/1.1
Host: api.zitadel.example.com
Authorization: Bearer dEnT7H7UfG1yHUzMGxPDnXvALn0fJE2WgQRjxHC...
Content-Type: application/json

No Token Refresh

Unlike OAuth-based methods, PATs do not automatically refresh:
# PAT remains static - no automatic refresh
client = Zitadel::Client::Zitadel.with_access_token(
  ENV['ZITADEL_HOST'],
  ENV['ZITADEL_PAT']
)

# This works until the token expires
client.users.list_users

# After expiration, you'll get an error
# You must manually generate a new token

Thread Safety

The PersonalAccessTokenAuthenticator is thread-safe since it only reads the static token:
require 'zitadel-client'

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

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

threads.each(&:join)

Common Use Cases

Development and Testing

PATs are perfect for local development:
require 'zitadel-client'

# Simple setup for local testing
client = Zitadel::Client::Zitadel.with_access_token(
  "http://localhost:8080",  # Local Zitadel instance
  ENV['ZITADEL_PAT']
)

# Quick iteration without OAuth setup
response = client.users.list_users
puts "Found #{response.result.length} users"

Scripts and Automation

Use PATs for one-off scripts:
#!/usr/bin/env ruby
require 'zitadel-client'

# Quick script to export users
client = Zitadel::Client::Zitadel.with_access_token(
  ENV['ZITADEL_HOST'],
  ENV['ZITADEL_PAT']
)

users = client.users.list_users.result
users.each do |user|
  puts "#{user.user_name}: #{user.preferred_login_name}"
end

CI/CD Pipelines

PATs can be used in CI/CD for testing:
.github/workflows/test.yml
name: Test
on: [push]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      
      - name: Run integration tests
        env:
          ZITADEL_HOST: ${{ secrets.ZITADEL_HOST }}
          ZITADEL_PAT: ${{ secrets.ZITADEL_PAT }}
        run: bundle exec rspec
For production CI/CD, prefer Private Key JWT for better security and automatic token refresh.

Debugging Production Issues

Generate a temporary PAT to debug production:
require 'zitadel-client'

# Temporary PAT for debugging (expires in 1 hour)
client = Zitadel::Client::Zitadel.with_access_token(
  ENV['PRODUCTION_ZITADEL_HOST'],
  ENV['TEMP_DEBUG_PAT']
) do |config|
  config.debug = true  # Enable verbose logging
end

# Debug specific issue
begin
  user = client.users.get_user_by_id(user_id)
  puts user.inspect
rescue => e
  puts "Error: #{e.message}"
  puts e.backtrace
end

Security Considerations

When to Use PATs

  • Local development and testing
  • Short-lived debugging sessions
  • Personal scripts and automation
  • Proof of concepts and prototypes
  • CI/CD test environments
  • Production applications
  • Long-running services
  • Applications deployed to multiple servers
  • Services requiring high security
  • Applications handling sensitive data

Token Expiration

PATs have a fixed expiration. Plan for token rotation:
require 'zitadel-client'

def create_client
  token = ENV['ZITADEL_PAT']
  
  if token.nil? || token.empty?
    raise "ZITADEL_PAT not set. Generate a new token at: #{ENV['ZITADEL_HOST']}/users/me/pats"
  end
  
  Zitadel::Client::Zitadel.with_access_token(
    ENV['ZITADEL_HOST'],
    token
  )
end

begin
  client = create_client
  client.settings.get_general_settings
rescue Zitadel::Client::ZitadelError => e
  if e.message.include?('unauthorized') || e.message.include?('invalid_token')
    puts "Token expired. Generate a new PAT at: #{ENV['ZITADEL_HOST']}/users/me/pats"
  end
  raise
end

Token Scope

PATs inherit the permissions of the user who created them:
# PAT has same permissions as the creating user
client = Zitadel::Client::Zitadel.with_access_token(
  ENV['ZITADEL_HOST'],
  ENV['ZITADEL_PAT']
)

# This succeeds if the user has permission
client.users.list_users

# This fails if the user lacks permission
begin
  client.organizations.add_organization(...)
rescue Zitadel::Client::ApiError => e
  puts "Permission denied: #{e.message}"
end
Create PATs from service users with minimal required permissions, not from admin accounts.

Token Storage

Never store PATs in:
  • Source code
  • Git repositories
  • Public configuration files
  • Client-side code
  • Logs or error messages
Best practices:
# ✅ Good - Environment variable
client = Zitadel::Client::Zitadel.with_access_token(
  ENV['ZITADEL_HOST'],
  ENV['ZITADEL_PAT']
)

# ✅ Good - Secret management (development)
require 'dotenv'
Dotenv.load('.env.local')

client = Zitadel::Client::Zitadel.with_access_token(
  ENV['ZITADEL_HOST'],
  ENV['ZITADEL_PAT']
)

# ❌ Bad - Hardcoded token
client = Zitadel::Client::Zitadel.with_access_token(
  "https://api.zitadel.example.com",
  "dEnT7H7UfG1yHUzMGxPDnXvALn0fJE2WgQRjxHC..."
)

Troubleshooting

Invalid Token

Error:
ApiError: Unauthorized
Solutions:
  1. Verify the token is correct (no extra spaces or newlines)
  2. Check that the token hasn’t expired
  3. Ensure the token was copied completely
  4. Generate a new token if needed

Token Expired

Error:
ApiError: Token expired
Solutions:
  1. Generate a new PAT in Zitadel console
  2. Update the environment variable with the new token
  3. Restart your application

Permission Denied

Error:
ApiError: Permission denied
Solutions:
  1. Verify the user who created the PAT has the required permissions
  2. Check the user’s role assignments in Zitadel
  3. Create the PAT from a service user with appropriate roles

Wrong Host

Error:
ZitadelError: Connection refused
Solutions:
  1. Verify the Zitadel host URL is correct
  2. Ensure you’re using HTTPS (not HTTP) for production
  3. Check network connectivity

Debug Authentication Issues

Enable debug logging to see authentication details:
client = Zitadel::Client::Zitadel.with_access_token(
  ENV['ZITADEL_HOST'],
  ENV['ZITADEL_PAT']
) do |config|
  config.debug = true
end

# Check if token is being sent correctly
begin
  client.settings.get_general_settings
rescue => e
  puts "Error: #{e.message}"
  # Debug output will show the full HTTP request/response
end

Migrating to Production Authentication

When moving from development to production, migrate from PATs to more secure methods:
require 'zitadel-client'

# Development - using PAT
if ENV['RAILS_ENV'] == 'development'
  client = Zitadel::Client::Zitadel.with_access_token(
    ENV['ZITADEL_HOST'],
    ENV['ZITADEL_PAT']
  )
else
  # Production - using Private Key JWT
  client = Zitadel::Client::Zitadel.with_private_key(
    ENV['ZITADEL_HOST'],
    ENV['ZITADEL_KEY_FILE']
  )
end
1

Choose Production Method

Select Private Key JWT or Client Credentials based on your security requirements.
2

Create Service User

Create a dedicated service user in Zitadel for production.
3

Generate Credentials

Generate either a private key (for JWT) or client credentials.
4

Update Code

Replace with_access_token calls with with_private_key or with_client_credentials.
5

Test

Test the new authentication in staging before deploying to production.
6

Revoke PAT

After successful migration, revoke the development PAT.

Comparison with Other Methods

FeaturePATClient CredentialsPrivate Key JWT
Setup Time1 minute5 minutes10 minutes
Token Refresh❌ Manual✅ Automatic✅ Automatic
Production Ready❌ No✅ Yes✅ Yes
Security LevelMediumHighHighest
Best ForDevelopmentServer-to-serverProduction
Secret TypeToken stringClient secretPrivate key file

Source Code Reference

The implementation can be found in:
  • Main entry point: lib/zitadel/client/zitadel.rb:95-97
  • Authenticator: lib/zitadel/client/auth/personal_access_token_authenticator.rb
  • Base class: lib/zitadel/client/auth/authenticator.rb

Next Steps

Private Key JWT

Secure production authentication

Client Credentials

OAuth2 server-to-server

Additional Resources

Build docs developers (and LLMs) love