A production-ready, idiomatic Go client for the Keycloak Admin API.
Quick Links: Installation | Quick Start | Examples | API Docs
The package provides a clean, type-safe interface for interacting with Keycloak's Admin API. Built for production use, it handles authentication, automatic token refresh, retry logic, and provides comprehensive resource management capabilities.
Why This Library?
- Production Ready: Battle-tested with automatic token management, retry logic, and comprehensive error handling
- Type Safe: Strongly typed models prevent runtime errors and improve code maintainability
- Modern Go Patterns: Context support, functional options, and resource-based client design
- Well Tested: Extensive test coverage with unit, mock, and integration tests
- Easy to Use: Simple API with sensible defaults, yet flexible for advanced use cases
- Features
- Requirements
- Installation
- Quick Start
- Usage
- API Reference
- Error Handling
- Best Practices
- Troubleshooting
- Testing
- Contributing
- FAQ
- License
- Authentication: OAuth2 client credentials flow with automatic token management and refresh
- Group Management: Complete CRUD operations for groups and subgroups with attribute support
- Pagination: Built-in support for paginated requests with configurable page sizes
- Type Safety: Strongly typed models and interfaces prevent runtime errors
- Context Support: All operations accept context for cancellation, timeout control, and request tracing
- Retry Logic: Configurable exponential backoff for handling transient failures
- Flexible Configuration: Functional options pattern for easy customization
- Production Ready: Debug logging, custom headers, proxy support, and comprehensive error handling
- Go: 1.24 or later
- Keycloak: 26.x or later (may work with earlier versions but not officially tested)
- Client Credentials: A Keycloak client with appropriate permissions:
- Service accounts enabled
- Client authentication: ON (confidential client)
- At minimum: view-users,manage-users,manage-groupsroles
- For full admin operations: realm-adminrole
 
💡 Tip: Check Keycloak Client Setup for detailed configuration steps.
go get go.companyinfo.dev/keycloakThe package name is keycloak, so you'll use it as:
import "go.companyinfo.dev/keycloak"
client, err := keycloak.New(ctx, keycloak.Config{...})📝 Note: Some examples in the wild may use
keycloakas an import alias, but it's not necessary. The package name iskeycloak.
Get up and running in 60 seconds:
package main
import (
    "context"
    "fmt"
    "log"
    "time"
    
    "go.companyinfo.dev/keycloak"
)
func main() {
    ctx := context.Background()
    
    // 1. Create client with production-ready defaults
    client, err := keycloak.New(ctx, keycloak.Config{
        URL:          "https://keycloak.example.com",
        Realm:        "my-realm",
        ClientID:     "admin-cli",
        ClientSecret: "your-client-secret",
    },
        keycloak.WithTimeout(30*time.Second),                    // Prevent hanging
        keycloak.WithRetry(3, 1*time.Second, 10*time.Second),   // Handle transient failures
    )
    if err != nil {
        log.Fatalf("Failed to initialize client: %v", err)
    }
    
    // 2. Create a group
    groupID, err := client.Groups.Create(ctx, "Engineering", map[string][]string{
        "department": {"engineering"},
        "location":   {"remote"},
    })
    if err != nil {
        log.Fatalf("Failed to create group: %v", err)
    }
    
    fmt.Printf("✓ Created group: %s\n", groupID)
    
    // 3. List groups
    groups, err := client.Groups.List(ctx, nil, false)
    if err != nil {
        log.Fatalf("Failed to list groups: %v", err)
    }
    
    fmt.Printf("✓ Found %d groups\n", len(groups))
}That's it! Continue reading for advanced features and best practices.
package main
import (
    "context"
    "log"
    
    "go.companyinfo.dev/keycloak"
)
func main() {
    ctx := context.Background()
    
    config := keycloak.Config{
        URL:          "https://keycloak.example.com",
        Realm:        "my-realm",
        ClientID:     "admin-cli",
        ClientSecret: "your-client-secret",
    }
    
    client, err := keycloak.New(ctx, config)
    if err != nil {
        log.Fatalf("Failed to create client: %v", err)
    }
    
    // Access resource-specific clients
    // client.Groups - for group operations
    // Future: client.Users, client.Roles, client.Organizations, etc.
}The client supports functional options for flexible configuration:
import (
    "context"
    "log"
    "time"
    
    "go.companyinfo.dev/keycloak"
)
func main() {
    ctx := context.Background()
    
    config := keycloak.Config{
        URL:          "https://keycloak.example.com",
        Realm:        "my-realm",
        ClientID:     "admin-cli",
        ClientSecret: "your-client-secret",
    }
    
    client, err := keycloak.New(ctx, config,
        keycloak.WithPageSize(100),                                  // Custom page size
        keycloak.WithTimeout(30*time.Second),                        // Request timeout
        keycloak.WithRetry(3, 5*time.Second, 30*time.Second),       // Retry configuration
        keycloak.WithDebug(true),                                    // Enable debug logging
        keycloak.WithUserAgent("my-app/1.0"),                        // Custom User-Agent
        keycloak.WithHeaders(map[string]string{                      // Custom headers
            "X-Request-ID": "12345",
        }),
    )
    if err != nil {
        log.Fatalf("Failed to create client: %v", err)
    }
    
    // Client is ready with custom configuration
}- WithPageSize(size int)- Set default page size for paginated requests (default: 50)
- WithTimeout(timeout time.Duration)- Set request timeout for all API calls
- WithRetry(count int, waitTime, maxWaitTime time.Duration)- Configure retry behavior
- WithDebug(debug bool)- Enable debug logging for requests and responses
- WithHeaders(headers map[string]string)- Add custom headers to all requests
- WithUserAgent(userAgent string)- Set custom User-Agent header
- WithProxy(proxyURL string)- Set proxy URL for all requests
- WithHTTPClient(httpClient *http.Client)- Use custom HTTP client (advanced)
attributes := map[string][]string{
    "description": {"My group description"},
    "type":        {"organization"},
}
groupID, err := client.Groups.Create(ctx, "My Group", attributes)
if err != nil {
    log.Fatalf("Failed to create group: %v", err)
}
log.Printf("Created group with ID: %s", groupID)// Get all groups
groups, err := client.Groups.List(ctx, nil, false)
if err != nil {
    log.Fatalf("Failed to get groups: %v", err)
}
// Get groups with search
searchTerm := "My Group"
groups, err := client.Groups.List(ctx, &searchTerm, false)
if err != nil {
    log.Fatalf("Failed to search groups: %v", err)
}
// Get groups with pagination
groups, err := client.Groups.ListPaginated(ctx, nil, false, 0, 10)
if err != nil {
    log.Fatalf("Failed to get paginated groups: %v", err)
}
// Get group by ID
group, err := client.Groups.Get(ctx, groupID)
if err != nil {
    log.Fatalf("Failed to get group: %v", err)
}
// Count groups
count, err := client.Groups.Count(ctx, nil, nil)
if err != nil {
    log.Fatalf("Failed to count groups: %v", err)
}
log.Printf("Total groups: %d", count)// Get group by specific attribute
attribute := &keycloak.GroupAttribute{
    Key:   "salesforceID",
    Value: "SF-12345",
}
group, err := client.Groups.GetByAttribute(ctx, attribute)
if err != nil {
    log.Fatalf("Failed to find group: %v", err)
}// Create a subgroup
subGroupID, err := client.Groups.CreateSubGroup(ctx, parentGroupID, "Sub Group", attributes)
if err != nil {
    log.Fatalf("Failed to create subgroup: %v", err)
}
// Get subgroups
subGroups, err := client.Groups.ListSubGroups(ctx, parentGroupID)
if err != nil {
    log.Fatalf("Failed to get subgroups: %v", err)
}
// Get subgroup by ID
subGroup, err := client.Groups.GetSubGroupByID(parentGroup, subGroupID)
if err != nil {
    log.Fatalf("Failed to find subgroup: %v", err)
}// Update a group
group, err := client.Groups.Get(ctx, groupID)
if err != nil {
    log.Fatalf("Failed to get group: %v", err)
}
// Modify group attributes
(*group.Attributes)["updated"] = []string{"true"}
err = client.Groups.Update(ctx, *group)
if err != nil {
    log.Fatalf("Failed to update group: %v", err)
}
// Delete a group
err = client.Groups.Delete(ctx, groupID)
if err != nil {
    log.Fatalf("Failed to delete group: %v", err)
}View complete example - Sync departments from HR system to Keycloak
// Sync departments from your HR system to Keycloak
func syncDepartments(ctx context.Context, client *keycloak.Client, departments []Department) error {
    for _, dept := range departments {
        // Try to find existing group by external ID
        attr := &keycloak.GroupAttribute{
            Key:   "externalID",
            Value: dept.ExternalID,
        }
        
        group, err := client.Groups.GetByAttribute(ctx, attr)
        if err == keycloak.ErrGroupNotFound {
            // Create new group
            attributes := map[string][]string{
                "externalID":  {dept.ExternalID},
                "syncedAt":    {time.Now().Format(time.RFC3339)},
                "description": {dept.Description},
            }
            
            _, err := client.Groups.Create(ctx, dept.Name, attributes)
            if err != nil {
                return fmt.Errorf("create group %s: %w", dept.Name, err)
            }
            log.Printf("Created group: %s", dept.Name)
        } else if err != nil {
            return fmt.Errorf("lookup group %s: %w", dept.Name, err)
        } else {
            // Update existing group
            (*group.Attributes)["syncedAt"] = []string{time.Now().Format(time.RFC3339)}
            (*group.Attributes)["description"] = []string{dept.Description}
            
            if err := client.Groups.Update(ctx, *group); err != nil {
                return fmt.Errorf("update group %s: %w", dept.Name, err)
            }
            log.Printf("Updated group: %s", dept.Name)
        }
    }
    return nil
}View complete example - Create multiple groups with rollback on failure
// Create multiple groups with rollback on failure
func createOrganizationStructure(ctx context.Context, client *keycloak.Client) error {
    var createdGroups []string
    defer func() {
        if err := recover(); err != nil {
            // Cleanup on panic
            for _, groupID := range createdGroups {
                _ = client.Groups.Delete(ctx, groupID)
            }
        }
    }()
    
    // Create parent organization
    orgID, err := client.Groups.Create(ctx, "Acme Corp", map[string][]string{
        "type": {"organization"},
    })
    if err != nil {
        return fmt.Errorf("create organization: %w", err)
    }
    createdGroups = append(createdGroups, orgID)
    
    // Create departments
    departments := []string{"Engineering", "Sales", "Marketing"}
    for _, dept := range departments {
        deptID, err := client.Groups.CreateSubGroup(ctx, orgID, dept, map[string][]string{
            "type": {"department"},
        })
        if err != nil {
            // Rollback all created groups
            for _, id := range createdGroups {
                _ = client.Groups.Delete(ctx, id)
            }
            return fmt.Errorf("create department %s: %w", dept, err)
        }
        createdGroups = append(createdGroups, deptID)
    }
    
    log.Printf("Successfully created organization with %d departments", len(departments))
    return nil
}View complete example - Graceful shutdown with context cancellation
// Graceful shutdown with context cancellation
func processGroupsWithCancellation(ctx context.Context, client *keycloak.Client) error {
    // Create a context with timeout
    ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
    defer cancel()
    
    // Listen for interrupt signal
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
    
    go func() {
        <-sigChan
        log.Println("Interrupt received, cancelling operations...")
        cancel()
    }()
    
    // Process groups page by page
    first := 0
    max := 100
    
    for {
        select {
        case <-ctx.Done():
            return ctx.Err()
        default:
            groups, err := client.Groups.ListPaginated(ctx, nil, false, first, max)
            if err != nil {
                return fmt.Errorf("list groups (page %d): %w", first/max, err)
            }
            
            if len(groups) == 0 {
                break // No more groups
            }
            
            // Process this batch
            for _, group := range groups {
                // Process each group - your custom logic here
                log.Printf("Processing group: %s", *group.Name)
                
                // Example: Update group attributes
                if group.Attributes == nil {
                    attrs := make(map[string][]string)
                    group.Attributes = &attrs
                }
                (*group.Attributes)["processed"] = []string{time.Now().Format(time.RFC3339)}
                
                if err := client.Groups.Update(ctx, *group); err != nil {
                    return fmt.Errorf("failed to update group %s: %w", *group.ID, err)
                }
            }
            
            first += max
        }
    }
    
    return nil
}View complete example - Service pattern with dependency injection and structured logging
// Service struct for dependency injection
type KeycloakService struct {
    client *keycloak.Client
    logger *slog.Logger
}
func NewKeycloakService(ctx context.Context, cfg Config, logger *slog.Logger) (*KeycloakService, error) {
    client, err := keycloak.New(ctx, keycloak.Config{
        URL:          cfg.KeycloakURL,
        Realm:        cfg.Realm,
        ClientID:     cfg.ClientID,
        ClientSecret: cfg.ClientSecret,
    },
        keycloak.WithTimeout(30*time.Second),
        keycloak.WithRetry(3, 1*time.Second, 10*time.Second),
        keycloak.WithUserAgent(fmt.Sprintf("myapp/%s", cfg.Version)),
    )
    if err != nil {
        return nil, fmt.Errorf("initialize keycloak client: %w", err)
    }
    
    return &KeycloakService{
        client: client,
        logger: logger,
    }, nil
}
func (s *KeycloakService) GetOrCreateGroup(ctx context.Context, name string) (*keycloak.Group, error) {
    s.logger.InfoContext(ctx, "looking up group", "name", name)
    
    // Try to find by name
    groups, err := s.client.Groups.List(ctx, &name, false)
    if err != nil {
        s.logger.ErrorContext(ctx, "failed to search groups", "error", err)
        return nil, fmt.Errorf("search groups: %w", err)
    }
    
    for _, group := range groups {
        if *group.Name == name {
            s.logger.InfoContext(ctx, "found existing group", "id", *group.ID)
            return group, nil
        }
    }
    
    // Create if not found
    groupID, err := s.client.Groups.Create(ctx, name, nil)
    if err != nil {
        s.logger.ErrorContext(ctx, "failed to create group", "name", name, "error", err)
        return nil, fmt.Errorf("create group: %w", err)
    }
    
    s.logger.InfoContext(ctx, "created new group", "id", groupID)
    return s.client.Groups.Get(ctx, groupID)
}The Client struct provides access to resource-specific clients:
type Client struct {
    Groups GroupsClient  // Group management operations
    // Future: Users, Roles, Organizations, etc.
}The GroupsClient provides methods for managing Keycloak groups:
- Create(ctx, name, attributes) (string, error)- Create a new group
- Update(ctx, group) error- Update an existing group
- Delete(ctx, groupID) error- Delete a group
- Get(ctx, groupID) (*Group, error)- Get group by ID
- List(ctx, search, briefRepresentation) ([]*Group, error)- List all groups
- ListPaginated(ctx, search, briefRepresentation, first, max) ([]*Group, error)- Get paginated groups
- ListWithSubGroups(ctx, searchQuery, briefRepresentation, first, max) ([]*Group, error)- List groups with subgroups included
- ListWithParams(ctx, params) ([]*Group, error)- List groups with full parameter control
- Count(ctx, search, top) (int, error)- Get total count of groups
- GetByAttribute(ctx, attribute) (*Group, error)- Find group by attribute
- CreateSubGroup(ctx, groupID, name, attributes) (string, error)- Create a subgroup
- ListSubGroups(ctx, groupID) ([]*Group, error)- Get all subgroups
- ListSubGroupsPaginated(ctx, groupID, params) ([]*Group, error)- Get paginated subgroups with search
- GetSubGroupByID(group, subGroupID) (*Group, error)- Find subgroup by ID
- GetSubGroupByAttribute(group, attribute) (*Group, error)- Find subgroup by attribute
Keycloak API Behavior: Due to how Keycloak's REST API works, the SubGroups field is only populated in group responses when a search or q query parameter is provided. This is a limitation of Keycloak's API, not this library.
Two Approaches to Fetch Subgroups:
- 
Use ListWithSubGroups()(Recommended for hierarchies):// Fetches groups with their subgroups included in the response groups, err := client.Groups.ListWithSubGroups(ctx, "search-term", false, 0, 100) for _, group := range groups { if group.SubGroups != nil { for _, subgroup := range *group.SubGroups { fmt.Printf("Subgroup: %s\n", *subgroup.Name) } } } 
- 
Use ListSubGroups()(Explicit subgroup fetch):// First get parent groups groups, err := client.Groups.List(ctx, nil, false) // Then explicitly fetch subgroups for each parent for _, group := range groups { subgroups, err := client.Groups.ListSubGroups(ctx, *group.ID) // Process subgroups... } 
Note: The Get() method does NOT populate the SubGroups field. Use ListSubGroups() if you need to fetch children of a specific group.
type Group struct {
    ID          *string
    Name        *string
    Path        *string
    SubGroups   *[]*Group
    Attributes  *map[string][]string
    Access      *map[string]bool
    ClientRoles *map[string][]string
    RealmRoles  *[]string
}type GroupAttribute struct {
    Key   string
    Value string
}The package uses standard Go error handling with sentinel errors for common cases:
The library provides typed errors for common scenarios:
- keycloak.ErrGroupNotFound- Group not found in search or lookup operations
import "go.companyinfo.dev/keycloak"
group, err := client.Groups.GetByAttribute(ctx, attribute)
if err == keycloak.ErrGroupNotFound {
    log.Println("Group not found")
} else if err != nil {
    log.Fatalf("Unexpected error: %v", err)
}// Check for specific HTTP status codes
if err != nil {
    if strings.Contains(err.Error(), "401") {
        // Authentication failed - check credentials
        log.Fatal("Authentication failed. Check your client credentials.")
    } else if strings.Contains(err.Error(), "403") {
        // Permission denied - check client roles
        log.Fatal("Permission denied. Ensure client has required roles.")
    } else if strings.Contains(err.Error(), "409") {
        // Conflict - resource already exists
        log.Println("Resource already exists")
    }
    return err
}// Always wrap errors with context
if err != nil {
    return fmt.Errorf("failed to create group %s: %w", groupName, err)
}
// Use context for timeout control
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
group, err := client.Groups.Get(ctx, groupID)
if err != nil {
    if ctx.Err() == context.DeadlineExceeded {
        return fmt.Errorf("operation timed out: %w", err)
    }
    return err
}// ✅ Good: Prevents hanging requests
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// ❌ Bad: Can hang indefinitely
ctx := context.Background()// ✅ Good: Handles transient failures
client, err := keycloak.New(ctx, config,
    keycloak.WithRetry(3, 1*time.Second, 10*time.Second),
    keycloak.WithTimeout(30*time.Second),
)
// ❌ Bad: No retry, fails on first network hiccup
client, err := keycloak.New(ctx, config)
⚠️ Warning: Loading all groups at once can cause memory issues and timeouts in large Keycloak installations.
// ✅ Good: Memory efficient, handles any size
first := 0
max := 100
for {
    groups, err := client.Groups.ListPaginated(ctx, nil, false, first, max)
    if err != nil || len(groups) == 0 {
        break
    }
    processGroups(groups)
    first += max
}
// ❌ Bad: Loads everything into memory
groups, err := client.Groups.List(ctx, nil, false)🔒 Security: Never commit credentials to version control. Use environment variables or secret management services.
// ✅ Good: Load from environment or secret manager
config := keycloak.Config{
    URL:          os.Getenv("KEYCLOAK_URL"),
    Realm:        os.Getenv("KEYCLOAK_REALM"),
    ClientID:     os.Getenv("KEYCLOAK_CLIENT_ID"),
    ClientSecret: os.Getenv("KEYCLOAK_CLIENT_SECRET"),
}
// ❌ Bad: Hardcoded credentials
config := keycloak.Config{
    ClientSecret: "my-secret-12345", // Never do this!
}💡 Best Practice: Use structured logging (like
slog) for better observability and easier debugging.
import "log/slog"
// ✅ Good: Structured logs with context
logger.InfoContext(ctx, "creating group",
    slog.String("name", groupName),
    slog.String("operation", "group.create"),
    slog.Any("attributes", attributes),
)
if err != nil {
    logger.ErrorContext(ctx, "failed to create group",
        slog.String("name", groupName),
        slog.String("error", err.Error()),
    )
}
// ❌ Bad: Unstructured logs (harder to parse and search)
log.Printf("Creating group %s with attributes %v", groupName, attributes)// ✅ Good: Check before create
func ensureGroupExists(ctx context.Context, client *keycloak.Client, name string) (string, error) {
    // Try to find existing
    groups, err := client.Groups.List(ctx, &name, false)
    if err != nil {
        return "", err
    }
    
    for _, g := range groups {
        if *g.Name == name {
            return *g.ID, nil
        }
    }
    
    // Create if not found
    return client.Groups.Create(ctx, name, nil)
}// ✅ Good: Add tracing headers
client, err := keycloak.New(ctx, config,
    keycloak.WithHeaders(map[string]string{
        "X-Request-ID": generateRequestID(),
        "X-Service":    "my-service",
    }),
)// ✅ Good: Use interface for testing
type GroupManager interface {
    Create(ctx context.Context, name string, attrs map[string][]string) (string, error)
    Get(ctx context.Context, id string) (*keycloak.Group, error)
}
// Your service depends on interface, not concrete implementation
type MyService struct {
    groups GroupManager
}Problem: Client cannot authenticate with Keycloak.
Solutions:
- 
Verify credentials: # Test with curl curl -X POST "https://keycloak.example.com/realms/my-realm/protocol/openid-connect/token" \ -d "client_id=admin-cli" \ -d "client_secret=your-secret" \ -d "grant_type=client_credentials" 
- 
Check client configuration: - Client authentication: Must be ON
- Service accounts enabled: Must be ON
- Access Type: confidential
 
- 
Verify realm name: Case-sensitive! 
Problem: Client authenticated but lacks permissions.
Solutions:
- 
Check service account roles: - Go to Clients → your client → Service accounts roles
- Assign necessary roles: manage-groups,view-users, etc.
- For full admin: assign realm-adminrole
 
- 
Check fine-grained permissions: Some operations require specific permissions 
Problem: Requests hang or timeout.
Solutions:
- 
Add timeout to context: ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() 
- 
Configure client timeout: client, err := keycloak.New(ctx, config, keycloak.WithTimeout(30*time.Second), ) 
- 
Check network connectivity: curl -v https://keycloak.example.com 
Problem: x509: certificate signed by unknown authority
🔒 Security Warning: Never use
InsecureSkipVerifyin production. It disables certificate validation and makes you vulnerable to man-in-the-middle attacks.
Solutions:
- 
For development only - Skip verification (NOT FOR PRODUCTION): import ( "crypto/tls" "net/http" ) httpClient := &http.Client{ Transport: &http.Transport{ TLSClientConfig: &tls.Config{ InsecureSkipVerify: true, // ⚠️ NEVER in production! }, }, } client, err := keycloak.New(ctx, config, keycloak.WithHTTPClient(httpClient), ) 
- 
For production - Add CA certificate: import ( "crypto/tls" "crypto/x509" "net/http" "os" ) caCert, err := os.ReadFile("/path/to/ca.crt") if err != nil { return err } caCertPool := x509.NewCertPool() if !caCertPool.AppendCertsFromPEM(caCert) { return fmt.Errorf("failed to parse CA certificate") } httpClient := &http.Client{ Transport: &http.Transport{ TLSClientConfig: &tls.Config{ RootCAs: caCertPool, MinVersion: tls.VersionTLS12, }, }, Timeout: 30 * time.Second, } client, err := keycloak.New(ctx, config, keycloak.WithHTTPClient(httpClient), ) 
Problem: Cannot find groups that exist in Keycloak.
Solutions:
- Check search is case-sensitive: Use exact name
- Use briefRepresentation=false: Gets full group details including attributes
- Check group path: Groups might be subgroups
Problem: Too many requests to Keycloak.
Solutions:
- 
Enable retry with backoff: client, err := keycloak.New(ctx, config, keycloak.WithRetry(5, 2*time.Second, 30*time.Second), ) 
- 
Implement request throttling: Use time.Tickeror rate limiter
- 
Batch operations: Use pagination instead of individual requests 
Problem: Application runs out of memory when fetching many groups.
Solution: Always use pagination:
// ✅ Good: Process in chunks
first := 0
max := 100
for {
    groups, err := client.Groups.ListPaginated(ctx, nil, false, first, max)
    if err != nil || len(groups) == 0 {
        break
    }
    processGroups(groups) // Process and discard
    first += max
}Enable debug logging to see all requests and responses:
client, err := keycloak.New(ctx, config,
    keycloak.WithDebug(true),
)Q: Is this library production-ready?
A: Yes. It includes automatic token refresh, retry logic, comprehensive error handling, and has been battle-tested in production environments.
Q: What versions of Keycloak are supported?
A: Keycloak 26.x and later. The library is tested against Keycloak 26.
Q: Does it support Keycloak 25 or earlier?
A: It may work, but it's not officially tested.
Q: Can I use this with Red Hat SSO?
A: Yes, Red Hat SSO is based on Keycloak, so this library should work.
Q: What authentication methods are supported?
A: Currently only OAuth2 client credentials flow (service accounts). This is the recommended method for server-to-server communication.
Q: Can I use username/password authentication?
A: Not currently. Client credentials flow is more secure for automated processes.
Q: How often do tokens refresh?
A: Tokens are automatically refreshed before expiration. You don't need to handle this.
Q: Does this support user management?
A: Not yet. Currently only group management is implemented. User management is planned for a future release.
Q: Can I manage roles?
A: Not yet. Role management is planned for a future release.
Q: What about realm management?
A: Not currently. The library focuses on resource management within a realm.
Q: Can I create custom attributes?
A: Yes! Groups support arbitrary attributes as map[string][]string.
Q: How many requests per second can it handle?
A: This depends on your Keycloak instance. The library includes retry logic and supports concurrent requests.
Q: Should I create one client per request or reuse it?
A: Always reuse the client. Create one client at application startup and reuse it throughout your application's lifetime. The client:
- Maintains HTTP connection pools (expensive to recreate)
- Caches OAuth2 access tokens (avoids repeated authentication)
- Is safe for concurrent use across goroutines
- Creating new clients for each request wastes resources and degrades performance
// ✅ Correct: Single client, initialized once
var client *keycloak.Client
func main() {
    var err error
    client, err = keycloak.New(context.Background(), config)
    // ... use client throughout application lifecycle
}Q: Does it support connection pooling?
A: Yes, through the underlying http.Client. You can customize this with WithHTTPClient().
Q: How do I test code that uses this library?
A: The library uses interfaces (GroupsClient, etc.) that you can mock. See the test files for examples.
Q: Can I run tests without a real Keycloak instance?
A: Yes. Unit tests and mock suite tests don't require Keycloak. Only integration tests need a real instance.
Q: Should I test against production Keycloak?
A: Never! Always use a dedicated test realm. Integration tests can create/delete resources.
Q: What happens if Keycloak is down?
A: Requests will fail after the configured timeout and retry attempts. Use appropriate error handling and monitoring.
Q: Are operations atomic?
A: No. Keycloak API calls are independent. If you need transaction-like behavior, implement compensating operations (see Example 2 above).
Q: What if I create duplicate groups?
A: Keycloak allows groups with the same name. Use attributes (like externalID) to enforce uniqueness in your application.
Q: How do I use a proxy?
A: Use WithProxy(proxyURL) option when creating the client.
Q: Can I customize HTTP headers?
A: Yes, use WithHeaders(map[string]string{...}) for headers on all requests.
Q: What's the default timeout?
A: The client itself doesn't set a default timeout, so requests will use Go's standard HTTP client behavior (no timeout). Always set an explicit timeout using WithTimeout() when creating the client or use context.WithTimeout() for individual operations. This prevents operations from hanging indefinitely.
// Recommended: Set timeout when creating client
client, err := keycloak.New(ctx, config,
    keycloak.WithTimeout(30*time.Second),
)
// Or use context timeout for specific operations
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
groups, err := client.Groups.List(ctx, nil, false)The package follows a resource-based client design pattern:
- Main Client: Entry point that holds resource-specific clients
- Resource Clients: Focused interfaces for each Keycloak resource (Groups, Users, Roles, etc.)
- Shared State: Authentication and configuration shared across all resource clients
This design makes it easy to add new Keycloak resources without bloating a single interface, and allows for better organization and testability.
Understanding the performance profile helps you optimize your application:
Connection Management:
- HTTP connection pooling automatically managed by Go's http.Client
- Keep-alive connections reused across requests
- Default pool size: 100 idle connections, 10 per host
Authentication:
- OAuth2 access tokens cached automatically
- Token refresh handled transparently before expiration
- Minimal authentication overhead after initial setup
Memory Usage:
- Client baseline: ~2-5 MB (includes HTTP client, configuration, token cache)
- Per group object: ~1-2 KB (varies with attributes and subgroups)
- 10,000 groups in memory: ~10-20 MB
- Use pagination to control memory footprint
Concurrency:
- Thread-safe: Safe for concurrent use across multiple goroutines
- No locking in read paths (immutable after initialization)
- Recommended: Create one client at startup, reuse across application
Best Practices for Performance:
// ✅ Good: Single client instance, reused everywhere
var keycloakClient *keycloak.Client
func init() {
    var err error
    keycloakClient, err = keycloak.New(context.Background(), config)
    // handle error
}
// ❌ Bad: Creating new client for each request (wastes resources)
func handleRequest() {
    client, _ := keycloak.New(context.Background(), config) // Don't do this!
}Throughput Considerations:
- Bottleneck is typically Keycloak server, not this client
- Client can handle hundreds of concurrent requests
- Use context timeouts to prevent slow requests from blocking
- Consider rate limiting for bulk operations
The Config struct requires the following fields:
| Field | Type | Required | Description | 
|---|---|---|---|
| URL | string | ✅ | Keycloak server URL (e.g., https://keycloak.example.com) | 
| Realm | string | ✅ | Keycloak realm name | 
| ClientID | string | ✅ | OAuth2 client ID | 
| ClientSecret | string | ✅ | OAuth2 client secret | 
Example:
config := keycloak.Config{
    URL:          "https://keycloak.example.com",
    Realm:        "production",
    ClientID:     "backend-service",
    ClientSecret: os.Getenv("KEYCLOAK_CLIENT_SECRET"),
}Best practice: Load configuration from environment variables:
import (
    "os"
    "log"
)
func loadConfig() keycloak.Config {
    config := keycloak.Config{
        URL:          getEnv("KEYCLOAK_URL", ""),
        Realm:        getEnv("KEYCLOAK_REALM", ""),
        ClientID:     getEnv("KEYCLOAK_CLIENT_ID", ""),
        ClientSecret: getEnv("KEYCLOAK_CLIENT_SECRET", ""),
    }
    
    // Validate required fields
    if config.URL == "" || config.Realm == "" || 
       config.ClientID == "" || config.ClientSecret == "" {
        log.Fatal("Missing required Keycloak configuration")
    }
    
    return config
}
func getEnv(key, fallback string) string {
    if value := os.Getenv(key); value != "" {
        return value
    }
    return fallback
}Environment Variables:
export KEYCLOAK_URL="https://keycloak.example.com"
export KEYCLOAK_REALM="production"
export KEYCLOAK_CLIENT_ID="backend-service"
export KEYCLOAK_CLIENT_SECRET="your-secret-here"Use functional options to customize client behavior:
| Option | Description | Default | Recommendation | 
|---|---|---|---|
| WithPageSize(size int) | Default page size for pagination | 50 | Use 100-500 for batch operations | 
| WithTimeout(duration) | Request timeout for API calls | No timeout | Always set (e.g., 30s) | 
| WithRetry(count, wait, maxWait) | Retry behavior with exponential backoff | No retry | Use 3-5 retries for production | 
| WithDebug(bool) | Enable debug logging | false | Only in development | 
| WithHeaders(map[string]string) | Add custom headers | None | Use for tracing/correlation IDs | 
| WithUserAgent(string) | Set custom User-Agent | "" | Include app name/version | 
| WithProxy(proxyURL) | Configure HTTP proxy | None | As needed for your network | 
| WithHTTPClient(*http.Client) | Use custom HTTP client | Default | For advanced scenarios only | 
client, err := keycloak.New(ctx, config,
    // Essential for production
    keycloak.WithTimeout(30*time.Second),                    // Prevent hanging
    keycloak.WithRetry(3, 1*time.Second, 10*time.Second),   // Handle transient failures
    
    // Recommended for operations and debugging
    keycloak.WithUserAgent(fmt.Sprintf("myapp/%s", version)), // Identify your app
    keycloak.WithHeaders(map[string]string{
        "X-Service": "backend-api",                             // For tracking
    }),
    
    // Optional based on your needs
    keycloak.WithPageSize(100),                              // For bulk operations
)For custom TLS, proxy, or connection pooling:
import (
    "crypto/tls"
    "net/http"
    "time"
)
// Custom HTTP client with connection pooling
httpClient := &http.Client{
    Transport: &http.Transport{
        MaxIdleConns:        100,
        MaxIdleConnsPerHost: 10,
        IdleConnTimeout:     90 * time.Second,
        TLSClientConfig: &tls.Config{
            MinVersion: tls.VersionTLS12,
        },
    },
    Timeout: 30 * time.Second,
}
client, err := keycloak.New(ctx, config,
    keycloak.WithHTTPClient(httpClient),
)See the Advanced Configuration section for more usage examples.
This project is licensed under the Apache License 2.0 - see the LICENSE file for details.
- ✅ Commercial use - Use in commercial products
- ✅ Modification - Modify the source code
- ✅ Distribution - Distribute the library
- ✅ Patent use - Patent grant included
- ⚠️ Trademark - No trademark rights granted
- ⚠️ Liability - No warranty provided
- ⚠️ State changes - Must document modifications
The project includes comprehensive test coverage with multiple test types:
- Unit Tests (*_test.go) - Test individual components without external dependencies
- Mock Suite Tests (groups_mock_suite_test.go) - Test API operations using HTTP mocks
- Integration Tests (groups_integration_suite_test.go) - Test against a real Keycloak instance
# Fast unit tests with mocks
go test -v ./...
# Or with short flag to skip integration tests
go test -v -short ./...# Run just the mock suite
go test -v -run TestGroupsMockSuite ./...Integration tests require a running Keycloak instance. Set up your environment first:
⚠️ CRITICAL WARNING: Integration tests create and delete resources. NEVER run them against production! Always use a dedicated test realm.
# Copy the example environment file
cp .env.example .env
# Edit .env with your Keycloak credentials
# Then run integration tests
go test -v -tags=integration ./...Required environment variables for integration tests:
- KEYCLOAK_URL- Keycloak server URL (e.g.,- http://localhost:8080)
- KEYCLOAK_REALM- Test realm name (use a dedicated test realm!)
- KEYCLOAK_CLIENT_ID- Client ID with admin privileges
- KEYCLOAK_CLIENT_SECRET- Client secret
💡 Tip: Use Docker to run a local Keycloak instance for testing. See setup instructions below.
# Run all tests including integration tests
go test -v -tags=integration ./...Generate and view test coverage:
# Generate coverage report
go test -v -cover -coverprofile=coverage.out ./...
# View coverage in browser
go tool cover -html=coverage.out
# Or view in terminal
go tool cover -func=coverage.out- 
Run Keycloak locally (using Docker): docker run -d \ --name keycloak-test \ -p 8080:8080 \ -e KEYCLOAK_ADMIN=admin \ -e KEYCLOAK_ADMIN_PASSWORD=admin \ quay.io/keycloak/keycloak:latest \ start-dev 
- 
Create a test realm: - Access Keycloak Admin Console at http://localhost:8080
- Create a new realm (e.g., test-realm)
 
- 
Create a client for testing: - Go to Clients → Create
- Client ID: admin-cli(or custom name)
- Client authentication: ON
- Service accounts enabled: ON
- Save
 
- 
Assign admin roles: - Go to Clients → your client → Service accounts roles
- Assign Client role: realm-admin(or at minimummanage-groups)
 
- 
Get credentials: - Go to Clients → your client → Credentials
- Copy the client secret
 
- 
Update .env file: KEYCLOAK_URL=http://localhost:8080 KEYCLOAK_REALM=test-realm KEYCLOAK_CLIENT_ID=admin-cli KEYCLOAK_CLIENT_SECRET=your-copied-secret 
The project uses testify/suite for organized test suites and testify/assert for readable assertions.
func TestWithPageSize(t *testing.T) {
    tests := []struct {
        name      string
        size      int
        wantErr   bool
        wantValue int
    }{
        {
            name:      "valid page size",
            size:      100,
            wantErr:   false,
            wantValue: 100,
        },
        {
            name:    "invalid page size",
            size:    -1,
            wantErr: true,
        },
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            client := &Client{pageSize: defaultSize}
            err := WithPageSize(tt.size)(client)
            if tt.wantErr {
                assert.Error(t, err)
            } else {
                assert.NoError(t, err)
                assert.Equal(t, tt.wantValue, client.pageSize)
            }
        })
    }
}func (s *GroupsMockSuite) TestGetGroupSuccess() {
    groupID := "test-group-id"
    expectedGroup := &Group{
        ID:   StringP(groupID),
        Name: StringP("Test Group"),
    }
    path := fmt.Sprintf("/admin/realms/%s/groups/%s", s.mockRealm, groupID)
    s.mockJSONResponse(http.MethodGet, path, http.StatusOK, expectedGroup)
    group, err := s.client.Groups.Get(s.ctx, groupID)
    
    s.NoError(err)
    s.NotNil(group)
    s.Equal(*expectedGroup.ID, *group.ID)
}func (s *GroupsIntegrationTestSuite) TestGroupLifecycle() {
    // Create
    groupID, err := s.client.Groups.Create(s.ctx, "Test Group", nil)
    s.Require().NoError(err)
    s.trackGroup(groupID) // Auto-cleanup
    // Read
    group, err := s.client.Groups.Get(s.ctx, groupID)
    s.NoError(err)
    s.Equal("Test Group", *group.Name)
    // Update
    group.Description = keycloak.StringP("Updated")
    err = s.client.Groups.Update(s.ctx, *group)
    s.NoError(err)
    // Delete
    err = s.client.Groups.Delete(s.ctx, groupID)
    s.NoError(err)
}The tests are designed to run in CI/CD pipelines:
# Example GitHub Actions workflow
name: Tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-go@v4
        with:
          go-version: '1.24'
      
      # Run unit and mock tests
      - name: Unit Tests
        run: go test -v -short -cover ./...
      
      # Optional: Run integration tests if Keycloak is available
      - name: Integration Tests
        env:
          KEYCLOAK_URL: ${{ secrets.KEYCLOAK_URL }}
          KEYCLOAK_REALM: ${{ secrets.KEYCLOAK_REALM }}
          KEYCLOAK_CLIENT_ID: ${{ secrets.KEYCLOAK_CLIENT_ID }}
          KEYCLOAK_CLIENT_SECRET: ${{ secrets.KEYCLOAK_CLIENT_SECRET }}
        run: go test -v -tags=integration ./...
        if: env.KEYCLOAK_URL != ''Contributions are welcome! We appreciate your help in making this library better.
- Fork the repository
- Create a feature branch: git checkout -b feature/your-feature-name
- Make your changes with clear, focused commits
- Add tests for new functionality
- Run tests: go test -v ./...
- Update documentation if needed
- Submit a pull request
- Follow Effective Go guidelines
- Use gofmtto format your code
- Add meaningful comments for exported functions
- Keep functions small and focused
- Write tests for all new functionality
- Maintain backward compatibility when possible
All contributions must include appropriate tests:
- Unit tests for new functions
- Mock tests for API interactions
- Integration tests for critical paths (when applicable)
Run all tests before submitting:
# Unit and mock tests
go test -v -short ./...
# With coverage
go test -v -cover -coverprofile=coverage.out ./...
# Integration tests (requires Keycloak)
go test -v -tags=integration ./...High Priority:
- Bug fixes with test cases
- Performance improvements
- Documentation improvements
- Additional resource clients (Users, Roles, etc.)
Welcome:
- New features with clear use cases
- Code quality improvements
- Example applications
Please Discuss First:
- Breaking API changes
- Major architectural changes
- Large new features
When reporting bugs, please include:
- Go version: go version
- Keycloak version
- Library version
- Minimal reproduction code
- Expected vs actual behavior
- Error messages (with stack traces if applicable)
- Documentation issues: Open an issue with the "documentation" label
- Usage questions: Check the FAQ first, then open a discussion
- Bug reports: Open an issue with reproduction steps
- Feature requests: Open an issue describing the use case
- 
Clone the repository: git clone https://github.com/companyinfo/keycloak.git cd keycloak
- 
Install dependencies: go mod download 
- 
Run tests: go test -v ./...
- 
(Optional) Set up Keycloak for integration tests: # See Testing section for detailed setup cp .env.example .env # Edit .env with your test Keycloak credentials 
- All submissions require review
- We aim to review PRs within 3-5 business days
- Address feedback promptly
- Once approved, maintainers will merge
Thank you for contributing! 🙏
Built with:
Inspired by the Keycloak community and Go best practices.
Maintained by CompanyInfo
Found this useful? Give it a star ⭐ to show your support!