Design Pattern - Cascading Credential Resolution
The Problem: Your users need to authenticate with your API, but they work in different environments - local development, CI/CD pipelines, production servers, containerized deployments. Forcing a single authentication method creates friction and forces workarounds.
The Solution: Implement a cascading credential resolution chain that checks multiple sources in a predictable order, similar to how AWS SDK resolves credentials. The result? Users authenticate seamlessly across environments without changing code.
Real Impact: Reduced authentication-related support tickets by 70%, cut onboarding time from hours to minutes, and eliminated hardcoded credentials in production environments.
Table of Contents
Open Table of Contents
- The AWS Inspiration
- Our Implementation: API Credential Chain
- The Type-Safe Implementation
- The Credential Resolver
- Individual Resolvers
- Client Integration
- Error Handling and User Feedback
- Advanced Features
- Real-World Usage Patterns
- Benefits Realized
- Documentation: The Critical Piece
- Implementation Checklist
- Key Takeaways
- Conclusion
The AWS Inspiration
AWS SDK has mastered credential resolution. Here’s their chain:
Why This Works:
- Explicit Overrides: Environment variables take precedence (great for testing)
- Shared Config: Credentials file works for local development
- Zero-Config Production: IAM roles mean no secrets in containers
- Predictable: Developers know exactly what happens and when
The Pattern: Check multiple sources in priority order, use first valid credential found, fail with clear error if none found.
Our Implementation: API Credential Chain
Inspired by AWS, here’s our credential resolution chain:
Design Goals:
- Development-Friendly: Quick setup with
export API_ACCESS_TOKEN=... - Production-Safe: Token files with proper permissions (600)
- User-Friendly: OAuth flow as last resort for non-technical users
- Explicit: Configuration file for team-shared setups
The Type-Safe Implementation
Following the principle from our CLI design article:
“Type everything - Input/Output structs for all operations”
We applied this to credentials:
// ClientConfig defines how to authenticate
type ClientConfig struct {
// Required
ClientID string
// Optional - will be resolved via credential chain
AccessToken string
AccessTokenFile string
ConfigFile string
// OAuth settings (for fallback flow)
OAuthEnabled bool
OAuthRedirectURL string
}
// Credential represents a resolved authentication credential
type Credential struct {
AccessToken string
Source CredentialSource // Where it came from
ExpiresAt *time.Time // Optional expiry
RefreshToken string // For OAuth refresh
}
// CredentialSource tracks provenance
type CredentialSource string
const (
SourceEnvironment CredentialSource = "environment"
SourceTokenFile CredentialSource = "token_file"
SourceConfigFile CredentialSource = "config_file"
SourceOAuthFlow CredentialSource = "oauth_flow"
SourceExplicit CredentialSource = "explicit" // Directly provided
)
Key Design Decisions:
- AccessToken is Optional: Users don’t need to provide it manually
- Source Tracking: Debug auth issues by knowing where credentials came from
- Expiry Support: Handle token refresh proactively
- Explicit Override: Allow direct token injection for testing
The Credential Resolver
Here’s the core resolution logic:
type CredentialResolver struct {
config ClientConfig
logger Logger
}
// Resolve implements the cascading credential chain
func (r *CredentialResolver) Resolve(ctx context.Context) (*Credential, error) {
// 1. Explicit token (highest priority)
if cred, err := r.fromExplicit(); err == nil {
r.logger.Debug("using explicit token")
return cred, nil
}
// 2. Environment variables
if cred, err := r.fromEnvironment(); err == nil {
r.logger.Debug("using token from environment")
return cred, nil
}
// 3. Token file
if cred, err := r.fromTokenFile(); err == nil {
r.logger.Debug("using token from file: %s", r.tokenFilePath())
return cred, nil
}
// 4. Config file
if cred, err := r.fromConfigFile(); err == nil {
r.logger.Debug("using token from config: %s", r.configFilePath())
return cred, nil
}
// 5. OAuth flow (interactive fallback)
if r.config.OAuthEnabled {
r.logger.Info("no credentials found, starting OAuth flow...")
cred, err := r.fromOAuthFlow(ctx)
if err != nil {
return nil, fmt.Errorf("oauth flow failed: %w", err)
}
// Persist for future use
if err := r.saveTokenFile(cred); err != nil {
r.logger.Warn("failed to save token: %v", err)
}
return cred, nil
}
// No credentials found
return nil, &AuthError{
Code: "NoCredentialsFound",
Message: "no credentials found in environment, files, or config",
Hint: "run 'myapp login' or set API_ACCESS_TOKEN environment variable",
}
}
Important Details:
- Early Return: Stop at first valid credential
- Logging: Debug visibility into resolution process
- Error Context: Tell users exactly what to do
- Token Persistence: Save OAuth tokens for next time
Individual Resolvers
1. Explicit Token (Testing Override)
func (r *CredentialResolver) fromExplicit() (*Credential, error) {
if r.config.AccessToken == "" {
return nil, errors.New("no explicit token")
}
return &Credential{
AccessToken: r.config.AccessToken,
Source: SourceExplicit,
}, nil
}
Use Case: Automated testing, CI/CD scripts
// Test with specific token
client := NewClient(&ClientConfig{
ClientID: "test-client",
AccessToken: "test-token-123",
})
2. Environment Variables
func (r *CredentialResolver) fromEnvironment() (*Credential, error) {
token := os.Getenv("API_ACCESS_TOKEN")
if token == "" {
return nil, errors.New("API_ACCESS_TOKEN not set")
}
return &Credential{
AccessToken: token,
Source: SourceEnvironment,
}, nil
}
Use Case: Quick local testing, containerized deployments
# Development
export API_ACCESS_TOKEN=dev_token_xyz
./myapp session list
# Docker
docker run -e API_ACCESS_TOKEN=$TOKEN myapp:latest
3. Token File
func (r *CredentialResolver) fromTokenFile() (*Credential, error) {
path := r.tokenFilePath()
// Check file permissions (should be 0600)
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("token file not found: %w", err)
}
if info.Mode().Perm() != 0600 {
return nil, &AuthError{
Code: "InsecureTokenFile",
Message: fmt.Sprintf("token file %s has insecure permissions", path),
Hint: fmt.Sprintf("run: chmod 600 %s", path),
}
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
token := strings.TrimSpace(string(data))
if token == "" {
return nil, errors.New("token file is empty")
}
return &Credential{
AccessToken: token,
Source: SourceTokenFile,
}, nil
}
func (r *CredentialResolver) tokenFilePath() string {
if r.config.AccessTokenFile != "" {
return r.config.AccessTokenFile
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "myapp", "token")
}
Use Case: Persistent local credentials, shared team tokens
# Setup once
echo "prod_token_abc" > ~/.config/myapp/token
chmod 600 ~/.config/myapp/token
# Use everywhere
myapp session list # Uses token file automatically
Security Features:
- Permission check (must be 0600)
- Clear error messages
- No secrets in shell history
4. Config File
type ConfigFile struct {
AccessToken string `json:"access_token"`
ClientID string `json:"client_id"`
Endpoint string `json:"endpoint"`
Metadata map[string]string `json:"metadata"`
}
func (r *CredentialResolver) fromConfigFile() (*Credential, error) {
path := r.configFilePath()
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("config file not found: %w", err)
}
var config ConfigFile
if err := json.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("invalid config file: %w", err)
}
if config.AccessToken == "" {
return nil, errors.New("no access_token in config")
}
return &Credential{
AccessToken: config.AccessToken,
Source: SourceConfigFile,
}, nil
}
func (r *CredentialResolver) configFilePath() string {
if r.config.ConfigFile != "" {
return r.config.ConfigFile
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "myapp", "config.json")
}
Use Case: Team configurations, multiple environments
{
"access_token": "team_shared_token",
"client_id": "prod-client",
"endpoint": "https://api.production.example.com",
"metadata": {
"team": "backend",
"environment": "production"
}
}
5. OAuth Flow (Interactive Fallback)
func (r *CredentialResolver) fromOAuthFlow(ctx context.Context) (*Credential, error) {
// Start local server for OAuth callback
server := &OAuthCallbackServer{
port: 8765,
done: make(chan *OAuthResult),
}
go server.Start()
defer server.Stop()
// Build authorization URL
state := randomString(32)
authURL := fmt.Sprintf(
"https://auth.example.com/authorize?client_id=%s&redirect_uri=%s&state=%s",
r.config.ClientID,
url.QueryEscape(r.config.OAuthRedirectURL),
state,
)
// Open browser
fmt.Printf("Opening browser for authentication...\n")
fmt.Printf("If browser doesn't open, visit: %s\n", authURL)
if err := browser.OpenURL(authURL); err != nil {
r.logger.Warn("failed to open browser: %v", err)
}
// Wait for callback
select {
case result := <-server.done:
if result.Error != "" {
return nil, fmt.Errorf("oauth error: %s", result.Error)
}
return &Credential{
AccessToken: result.AccessToken,
RefreshToken: result.RefreshToken,
ExpiresAt: result.ExpiresAt,
Source: SourceOAuthFlow,
}, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
Use Case: First-time setup, non-technical users
$ myapp login
Opening browser for authentication...
✓ Successfully authenticated as user@example.com
Token saved to ~/.config/myapp/token
Client Integration
Here’s how users create a client:
// Zero-config (uses credential chain)
client, err := myapp.NewClient(ctx, &myapp.ClientConfig{
ClientID: "my-app",
})
// The client automatically:
// 1. Checks environment variables
// 2. Looks for token file
// 3. Reads config file
// 4. Falls back to OAuth if needed
Internal Client Setup:
type Client struct {
config ClientConfig
credential *Credential
httpClient *http.Client
}
func NewClient(ctx context.Context, config *ClientConfig) (*Client, error) {
// Validate required fields
if config.ClientID == "" {
return nil, errors.New("ClientID is required")
}
// Resolve credentials
resolver := &CredentialResolver{
config: *config,
logger: config.Logger,
}
credential, err := resolver.Resolve(ctx)
if err != nil {
return nil, fmt.Errorf("credential resolution failed: %w", err)
}
// Create authenticated HTTP client
httpClient := &http.Client{
Transport: &AuthTransport{
credential: credential,
base: http.DefaultTransport,
},
}
return &Client{
config: *config,
credential: credential,
httpClient: httpClient,
}, nil
}
Error Handling and User Feedback
Structured Errors:
type AuthError struct {
Code string // Machine-readable
Message string // Human-readable
Hint string // What to do next
Source CredentialSource
}
func (e *AuthError) Error() string {
if e.Hint != "" {
return fmt.Sprintf("%s\nHint: %s", e.Message, e.Hint)
}
return e.Message
}
Example Error Messages:
# No credentials found
Error: no credentials found in environment, files, or config
Hint: run 'myapp login' or set API_ACCESS_TOKEN environment variable
# Insecure token file
Error: token file ~/.config/myapp/token has insecure permissions
Hint: run: chmod 600 ~/.config/myapp/token
# Invalid token
Error: authentication failed with token from environment
Hint: token may be expired, run 'myapp login' to refresh
Advanced Features
Credential Refresh
type RefreshableCredential struct {
*Credential
refresher TokenRefresher
}
func (c *RefreshableCredential) EnsureValid(ctx context.Context) error {
if c.ExpiresAt == nil || time.Now().Before(*c.ExpiresAt) {
return nil // Still valid
}
// Token expired, refresh it
newCred, err := c.refresher.Refresh(ctx, c.RefreshToken)
if err != nil {
return fmt.Errorf("token refresh failed: %w", err)
}
*c.Credential = *newCred
return nil
}
Credential Caching
type CredentialCache struct {
mu sync.RWMutex
cache map[string]*CachedCredential
}
type CachedCredential struct {
Credential *Credential
CachedAt time.Time
TTL time.Duration
}
func (c *CredentialCache) Get(key string) (*Credential, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
cached, ok := c.cache[key]
if !ok {
return nil, false
}
// Check if expired
if time.Since(cached.CachedAt) > cached.TTL {
return nil, false
}
return cached.Credential, true
}
Multi-Profile Support
// ~/.config/myapp/config.json
{
"profiles": {
"default": {
"access_token": "prod_token",
"endpoint": "https://api.example.com"
},
"staging": {
"access_token": "staging_token",
"endpoint": "https://staging.example.com"
},
"dev": {
"access_token": "dev_token",
"endpoint": "http://localhost:8080"
}
},
"active_profile": "default"
}
# Switch profiles
myapp config set-profile staging
# Override for single command
myapp --profile dev session list
Real-World Usage Patterns
Pattern 1: Local Development
# One-time setup
myapp login
# Opens browser, saves token to ~/.config/myapp/token
# Daily usage
myapp session list # Uses saved token
myapp session create # Uses saved token
Pattern 2: CI/CD Pipeline
# GitHub Actions
jobs:
test:
runs-on: ubuntu-latest
env:
API_ACCESS_TOKEN: ${{ secrets.API_TOKEN }}
steps:
- run: myapp session create --title "CI Build"
- run: myapp session analyze $SESSION_ID
Pattern 3: Production Server
# Server startup script
# Token in protected file (permissions: 0600, owner: appuser)
cat > /etc/myapp/token << EOF
$PRODUCTION_TOKEN
EOF
chmod 600 /etc/myapp/token
chown appuser:appuser /etc/myapp/token
# Application uses token file automatically
su - appuser -c "myapp daemon start"
Pattern 4: Docker Deployment
FROM myapp:latest
# Option 1: Environment variable
ENV API_ACCESS_TOKEN=${API_TOKEN}
# Option 2: Secret mounting
# docker run -v /secrets/token:/root/.config/myapp/token:ro myapp
Pattern 5: Temporary Override (Testing)
func TestWithCustomToken(t *testing.T) {
client, _ := myapp.NewClient(ctx, &myapp.ClientConfig{
ClientID: "test-client",
AccessToken: "test-specific-token", // Highest priority
})
// Test with controlled credentials
}
Benefits Realized
Documentation: The Critical Piece
The pattern only works if users understand it. Here’s our credential resolution documentation:
## Authentication
The CLI automatically finds credentials in this order:
1. **Explicit Token** (testing/override)
- Passed directly: `NewClient(&ClientConfig{AccessToken: "..."})`
2. **Environment Variable** (CI/CD, containers)
- Set: `export API_ACCESS_TOKEN=your_token`
3. **Token File** (local development)
- Location: `~/.config/myapp/token`
- Create: `myapp login` or `echo "token" > ~/.config/myapp/token && chmod 600 ~/.config/myapp/token`
4. **Config File** (team setups)
- Location: `~/.config/myapp/config.json`
- Format: `{"access_token": "...", "endpoint": "..."}`
5. **OAuth Flow** (first-time setup)
- Run: `myapp login`
- Opens browser for authentication
- Saves token to `~/.config/myapp/token`
### Which Method Should I Use?
- **Local development**: `myapp login` (easiest)
- **CI/CD**: Environment variable `API_ACCESS_TOKEN`
- **Production server**: Token file with restricted permissions
- **Team shared config**: Config file in version control (without token)
- **Testing**: Explicit token in code
### Debugging
See which credential source is being used:
```bash
myapp --debug session list
# Output: using token from environment
```
Implementation Checklist
When implementing cascading credential resolution:
- Define credential chain order - Document priority clearly
- Type all credential inputs - Use structs, not loose parameters
- Track credential source - For debugging and auditing
- Implement secure file handling - Check permissions, validate paths
- Provide OAuth fallback - Make first-time setup painless
- Add comprehensive logging - Debug which source was used
- Write clear error messages - Tell users exactly what to do
- Document all methods - With examples for each environment
- Support credential refresh - Handle token expiry gracefully
- Add testing overrides - Explicit token injection for tests
- Implement caching - Avoid re-resolving on every request
- Create CLI login command - Easy OAuth flow for users
Key Takeaways
Design Principles:
- Provide Multiple Paths: Different environments need different methods
- Clear Precedence: Users must know what overrides what
- Secure by Default: Enforce file permissions, avoid hardcoding
- Documentation is Critical: The pattern fails if users don’t understand it
Implementation Patterns:
- Type Everything: Input/Output structs for all operations
- Source Tracking: Know where credentials came from
- Fail with Clarity: Error messages should tell users what to do next
- Test Override: Always allow explicit credential injection
Developer Experience:
- Zero Config: Works out of the box when possible
- Explicit Override: Easy to customize when needed
- Environment Aware: Natural fit for dev/staging/prod
- Debug Friendly: Visibility into resolution process
Security Benefits:
- No Hardcoded Secrets: Credentials live in secure locations
- Permission Checking: Enforce file security
- Rotation Friendly: Update tokens without code changes
- Audit Trail: Track which credential source authenticated
Conclusion
Cascading credential resolution transforms authentication from a friction point into a seamless experience. By learning from AWS SDK’s battle-tested approach and applying type-safe design principles, you create an authentication system that works naturally across all environments.
The Pattern in One Sentence: Check multiple credential sources in priority order, use the first valid one found, and fail with actionable guidance.
Start Simple: Implement environment variable → token file → OAuth flow. You can always add more sources later (config file, secret managers, etc.) while maintaining backward compatibility.
Remember: The best authentication system is the one developers don’t have to think about.
Building credential systems? Have you implemented similar patterns? Let me know on LinkedIn or Twitter.
Related Reading:
Tags: #DesignPatterns #Security #Authentication #APIDesign #DeveloperExperience #BestPractices