In modern cloud-native applications, authentication requirements can vary significantly across different environments and use cases. In this post, I’ll share how we implemented a flexible authentication system for a webhook server that integrates with Kubernetes External Secrets Operator (ESO).
The Challenge
Our webhook server needed to support different authentication methods depending on the deployment environment and security requirements. We wanted to support:
- JWT verification with a private key
- JWKS-based verification (for Keycloak integration)
- Simple shared secret authentication
- An untrusted mode for development
The Solution: Strategy Pattern
We decided to use the Strategy pattern to implement our authentication system. This pattern allows us to encapsulate different authentication methods and switch between them easily using environment variables.
Here’s the core interface that defines our authentication strategy:
type AuthStrategy interface {
Authenticate(r *http.Request) (bool, error)
}
This simple interface allows us to implement different authentication methods while keeping the rest of our application code unchanged.
Implementing the Strategies
1. JWT Private Key Strategy
This strategy verifies JWTs using a private key stored in our secrets manager:
type JWTPrivateKeyStrategy struct {
secretClient *vsecm.Client
}
func (s *JWTPrivateKeyStrategy) Authenticate(r *http.Request) (bool, error) {
privateKey, err := s.secretClient.FetchPassword("jwt-private-key")
if err != nil {
return false, fmt.Errorf("failed to fetch private key: %v", err)
}
// JWT verification logic here
}
2. JWKS Strategy
For integration with Keycloak or other OIDC providers, we implemented JWKS-based verification:
type JWKSStrategy struct {
jwksURL string
keySet jwk.Set
}
func (s *JWKSStrategy) Authenticate(r *http.Request) (bool, error) {
// JWKS verification logic here
}
A key advantage of this approach is that we don’t need to store any secrets - we just use the public keys provided by the OIDC provider.
3. Shared Secret Strategy
For simpler deployments, we implemented a basic shared secret authentication:
type SharedSecretStrategy struct {
secretClient *vsecm.Client
}
func (s *SharedSecretStrategy) Authenticate(r *http.Request) (bool, error) {
secret, err := s.secretClient.FetchPassword("shared-secret")
if err != nil {
return false, fmt.Errorf("failed to fetch shared secret: %v", err)
}
// Secret comparison logic here
}
4. Untrusted Strategy (Development Mode)
For development environments, we have a strategy that bypasses authentication:
type UntrustedStrategy struct{}
func (s *UntrustedStrategy) Authenticate(r *http.Request) (bool, error) {
return true, nil
}
Switching Between Strategies
The magic happens in our strategy factory:
func getAuthStrategy() AuthStrategy {
strategy := os.Getenv("AUTH_STRATEGY")
switch strategy {
case "jwt_private_key":
return &JWTPrivateKeyStrategy{}
case "jwks":
return &JWKSStrategy{}
case "shared_secret":
return &SharedSecretStrategy{}
case "untrusted":
return &UntrustedStrategy{}
default:
panic(fmt.Sprintf("Unknown auth strategy: %s", strategy))
}
}
Using the Authentication System
The authentication system is implemented as middleware:
func authMiddleware(next http.HandlerFunc) http.HandlerFunc {
strategy := getAuthStrategy()
return func(w http.ResponseWriter, r *http.Request) {
authenticated, err := strategy.Authenticate(r)
if err != nil {
http.Error(w, fmt.Sprintf("Authentication error: %v", err),
http.StatusInternalServerError)
return
}
if !authenticated {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
}
}
Benefits of This Approach
- Flexibility: Easy to switch between authentication methods using environment variables.
- Maintainability: Each authentication strategy is isolated in its own type.
- Extensibility: New authentication methods can be added without changing existing code.
- Security: Proper separation of concerns and integration with secrets management.
- Developer Experience: Easy to use development mode without compromising production security.
Real-World Usage
In our case, we’re using this system with the External Secrets Operator webhook. Here’s how we configure it in different environments:
- Production:
AUTH_STRATEGY=jwks
for integration with Keycloak - Staging:
AUTH_STRATEGY=jwt_private_key
for testing with our own JWT implementation - Development:
AUTH_STRATEGY=untrusted
for rapid development
Lessons Learned
- Start Simple: Begin with the simplest strategy that meets your needs.
- Plan for Change: The Strategy pattern makes it easy to evolve your authentication system.
- Security First: Even in development, have a clear path to production-grade security.
- Configuration Matters: Environment-based configuration provides flexibility without complexity.
Conclusion
Building a flexible authentication system doesn’t have to be complicated. By using the Strategy pattern and good design principles, we created a system that’s both secure and adaptable to different environments and requirements.
Remember: security is not one-size-fits-all. The ability to adapt your authentication strategy to different environments and requirements is crucial for modern cloud-native applications.