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
AWS SDK has mastered credential resolution. Here’s their chain:
1. Environment Variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
↓ (if not found)
2. Credentials File (~/.aws/credentials)
↓ (if not found)
3. IAM Role (for EC2/ECS/Lambda instances)
↓ (if not found)
4. EC2 Instance Metadata Service
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:
1. Environment Variables (API_ACCESS_TOKEN)
↓ (if not found)
2. Token File (~/.config/myapp/token)
↓ (if not found)
3. Config File (~/.config/myapp/config.json)
↓ (if not found)
4. OAuth Flow (interactive browser authentication)
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
Before Cascading Resolution:
❌ Users confused about authentication setup
❌ Hardcoded tokens in scripts (security risk)
❌ Different auth methods per environment (complexity)
❌ Support tickets: "How do I authenticate?"
❌ Onboarding friction
After Cascading Resolution:
✅ Zero-config for most environments
✅ Automatic token resolution
✅ Security best practices by default
✅ 70% reduction in auth-related support tickets
✅ Onboarding: minutes instead of hours
✅ Explicit override for testing
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](https://www.linkedin.com/in/ketankhairnar) or [Twitter](https://x.com/_ketan/).
**Related Reading:**
- [Why CLIs matter in AI engineering](/blog/ai-engineering-cli-design-principles)
- [Building AI-First Documentation Systems](/blog/ai-first-documentation-structure)
---
**Tags:** #DesignPatterns #Security #Authentication #APIDesign #DeveloperExperience #BestPractices