Skip to content

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