Skip to content

RFC: API Key Management #510

Description

@lakhansamani

RFC: API Key Management

Phase: 2 — Authorization & M2M
Priority: P1 — High
Estimated Effort: Medium
Depends on: Fine-Grained Permissions (#508)


Problem Statement

Authorizer has no API key support. End-users and service integrations cannot create long-lived programmatic access tokens. WorkOS and Clerk both offer API key management. API keys are essential for developer platforms, SaaS integrations, and CLI tools that need to authenticate without interactive OAuth flows.


Current Architecture Context

  • Authentication currently requires interactive flows (password, OTP, magic link, social OAuth)
  • JWT access tokens expire in 30 minutes — too short for programmatic use
  • Refresh tokens last 1 year but require the full OAuth refresh flow
  • No API key schema or middleware exists
  • ClientCheckMiddleware validates X-Authorizer-Client-ID header — separate concern from user API keys
  • Fine-grained permissions (RFC: Fine-Grained Permissions Model #508) will provide the permission model that API keys can reference

Proposed Solution

1. API Key Schema

New schema: internal/storage/schemas/api_key.go

type APIKey struct {
    ID             string `json:"id" gorm:"primaryKey;type:char(36)"`
    Name           string `json:"name" gorm:"type:varchar(256)"`                            // user-provided label
    KeyHash        string `json:"-" gorm:"type:varchar(256);uniqueIndex"`                    // SHA-256 hash of the key
    KeyPrefix      string `json:"key_prefix" gorm:"type:varchar(12)"`                       // first 8 chars for identification
    UserID         string `json:"user_id" gorm:"type:char(36);index:idx_apikey_user"`
    OrganizationID string `json:"organization_id" gorm:"type:char(36);index"`
    Permissions    string `json:"permissions" gorm:"type:text"`                              // comma-separated permission names
    ExpiresAt      int64  `json:"expires_at" gorm:"index"`                                  // 0 = never expires
    LastUsedAt     int64  `json:"last_used_at"`
    LastUsedIP     string `json:"last_used_ip" gorm:"type:varchar(45)"`
    IsActive       bool   `json:"is_active" gorm:"type:bool;default:true"`
    CreatedAt      int64  `json:"created_at" gorm:"autoCreateTime"`
}

Key format: ak_ prefix + 48-char cryptographically random base62 string
Example: ak_7Bx9kLmN3pQrStUvWxYz1234567890AbCdEfGhIjKl

Storage: Only the SHA-256 hash is stored. The full key is returned once at creation time. KeyPrefix (ak_7Bx9kLmN) stored for identification in list views.

2. Key Generation & Storage

func GenerateAPIKey() (plaintext string, hash string, prefix string) {
    // Generate 48 bytes of cryptographic randomness
    raw := make([]byte, 48)
    crypto_rand.Read(raw)
    
    // Encode as base62 with prefix
    plaintext = "ak_" + base62.Encode(raw)
    
    // Store SHA-256 hash (not bcrypt — API keys are validated on every request, needs to be fast)
    hashBytes := sha256.Sum256([]byte(plaintext))
    hash = hex.EncodeToString(hashBytes[:])
    
    prefix = plaintext[:11] // "ak_" + first 8 chars
    
    return plaintext, hash, prefix
}

Why SHA-256 over bcrypt: API keys are validated on every API request. Bcrypt's deliberate slowness (100ms+) would add unacceptable latency per request. SHA-256 is sufficient because API keys have high entropy (48 random bytes = 384 bits), making brute force infeasible even with fast hashing. This is the same approach used by GitHub, Stripe, and AWS.

3. Authentication Middleware

New middleware: internal/http_handlers/api_key_middleware.go

API keys are accepted via Authorization: Bearer ak_... header. The middleware intercepts requests before they reach the GraphQL resolver:

func (h *httpProvider) APIKeyMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        authHeader := c.GetHeader("Authorization")
        if !strings.HasPrefix(authHeader, "Bearer ak_") {
            c.Next() // Not an API key, continue to normal auth
            return
        }
        
        key := strings.TrimPrefix(authHeader, "Bearer ")
        
        // Hash the provided key
        hashBytes := sha256.Sum256([]byte(key))
        keyHash := hex.EncodeToString(hashBytes[:])
        
        // Look up by hash
        apiKey, err := store.GetAPIKeyByHash(ctx, keyHash)
        if err != nil || apiKey == nil || !apiKey.IsActive {
            c.AbortWithStatusJSON(401, gin.H{"error": "invalid_api_key"})
            return
        }
        
        // Check expiry
        if apiKey.ExpiresAt > 0 && apiKey.ExpiresAt < time.Now().Unix() {
            c.AbortWithStatusJSON(401, gin.H{"error": "api_key_expired"})
            return
        }
        
        // Load user
        user, err := store.GetUserByID(ctx, apiKey.UserID)
        if err != nil || user == nil {
            c.AbortWithStatusJSON(401, gin.H{"error": "user_not_found"})
            return
        }
        
        // Set context with user identity and API key permissions
        c.Set("user", user)
        c.Set("api_key_id", apiKey.ID)
        c.Set("api_key_permissions", parsePermissions(apiKey.Permissions))
        c.Set("auth_method", "api_key")
        
        // Update last_used_at (async, non-blocking)
        go store.UpdateAPIKeyLastUsed(ctx, apiKey.ID, c.ClientIP())
        
        c.Next()
    }
}

Permission enforcement: When auth_method == "api_key", GraphQL resolvers check that the API key's permissions include the required permission for the operation. API key permissions are a subset of the user's permissions — a key cannot grant more access than its owner has.

4. Storage Interface Methods

AddAPIKey(ctx context.Context, key *schemas.APIKey) (*schemas.APIKey, error)
GetAPIKeyByHash(ctx context.Context, keyHash string) (*schemas.APIKey, error)
GetAPIKeyByID(ctx context.Context, id string) (*schemas.APIKey, error)
ListAPIKeysByUserID(ctx context.Context, userID string, pagination *model.Pagination) ([]*schemas.APIKey, *model.Pagination, error)
UpdateAPIKeyLastUsed(ctx context.Context, id string, ip string) error
DeleteAPIKey(ctx context.Context, id string) error
DeleteExpiredAPIKeys(ctx context.Context) error

5. GraphQL API

User-facing (users manage their own API keys):

type APIKey {
    id: ID!
    name: String!
    key_prefix: String!             # "ak_7Bx9kLmN" — for identification
    permissions: [String!]!
    expires_at: Int64
    last_used_at: Int64
    last_used_ip: String
    is_active: Boolean!
    created_at: Int64!
}

# Only returned on create — full key shown once
type APIKeyWithSecret {
    api_key: APIKey!
    key: String!                    # Full plaintext key, shown only once
}

type Mutation {
    create_api_key(params: CreateAPIKeyInput!): APIKeyWithSecret!
    revoke_api_key(id: ID!): Response!
}

type Query {
    api_keys(params: PaginatedInput): APIKeys!
}

input CreateAPIKeyInput {
    name: String!
    permissions: [String!]!         # Must be subset of user's permissions
    expires_at: Int64               # Optional, 0 = never expires
}

Admin API:

type Query {
    _user_api_keys(user_id: ID!): APIKeys!
}

type Mutation {
    _revoke_api_key(id: ID!): Response!
}

6. Key Rotation

Users create a new key before revoking the old one (zero-downtime rotation):

  1. create_api_key(name: "billing-service-v2", ...) → new key
  2. Update the consuming service with the new key
  3. revoke_api_key(id: "old-key-id") → old key deactivated

No built-in atomic rotation — this is the standard pattern used by AWS, Stripe, and GitHub.


Security Considerations

  • Keys stored as SHA-256 hash — cannot be recovered from database
  • Key shown only once at creation
  • Permissions are intersection of key's permissions and user's current permissions (if user loses a permission, the key loses it too)
  • Expired keys are automatically rejected by middleware
  • Background cleanup job removes expired keys: --api-key-cleanup-interval=24h
  • Rate limiting applied per API key (uses same infrastructure as RFC: Rate Limiting & Brute Force Protection #501)
  • All API key usage logged to audit log

CLI Configuration Flags

--enable-api-keys=true                     # Enable API key feature
--api-key-max-per-user=25                  # Maximum keys per user
--api-key-max-expiry-days=365              # Maximum expiry duration (0 = unlimited)
--api-key-cleanup-interval=24h             # How often to clean up expired keys

Migration Strategy

  1. Create api_keys table/collection across all DB providers
  2. Add storage interface methods
  3. Add API key middleware to Gin chain (before auth handlers, after rate limiting)
  4. Add GraphQL types and resolvers
  5. No changes to existing JWT-based auth flow

Testing Plan

  • Integration test: create key → use key to authenticate → verify user context
  • Test expired key rejection
  • Test revoked key rejection
  • Test permission enforcement (key can't access beyond its permissions)
  • Test user permission downgrade affects key access
  • Test key prefix identification in list view
  • Test rate limiting per API key
  • Test max keys per user limit

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions