API Keys Management
This document describes how API keys work for organization integrations, including encryption, generation, rotation, and usage.
Overview
Organizations use API keys to integrate with external services. These keys are:
- Stored encrypted in the database using AES-256-GCM
- Organization-scoped - Each organization has its own set of keys
- Service-specific - Keys are tied to specific integration services
- Admin-only access - Only organization admins can view or manage keys
Database Storage
Schema
API keys are stored in the organization_integrations table:
CREATE TABLE organization_integrations (
id BIGSERIAL PRIMARY KEY,
organization_id BIGINT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
title TEXT, -- Human-readable name
service integration_service NOT NULL, -- enum of supported external services
api_key_encrypted BYTEA NOT NULL, -- AES-256-GCM encrypted
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);Field Details
| Field | Type | Description |
|---|---|---|
id | BIGSERIAL | Primary key |
organization_id | BIGINT | Foreign key to organizations table |
title | TEXT | Human-readable label (e.g., "CRM Production", "CRM Staging") |
service | integration_service | Enum of supported external services |
api_key_encrypted | BYTEA | Encrypted API key (binary data) |
created_at | TIMESTAMPTZ | When the key was created |
updated_at | TIMESTAMPTZ | When the key was last updated |
Encryption
Algorithm
API keys are encrypted using AES-256-GCM (Galois/Counter Mode), which provides:
- Confidentiality - Data is encrypted and unreadable without the key
- Integrity - Tampered ciphertext will fail authentication
- Authentication - Ensures data hasn't been modified
Storage Format
The encrypted data is stored as PostgreSQL bytea (binary data):
[Nonce (12 bytes)][Ciphertext (variable)][Auth Tag (16 bytes)]- Nonce: Random value generated for each encryption operation
- Ciphertext: The encrypted API key
- Auth Tag: Authentication tag for verifying integrity
Encryption Key Management
The encryption key is stored as an environment variable on the application server:
API_KEY_ENCRYPTION_KEY=hex_encoded_256_bit_key_hereImportant:
- The encryption key MUST be 32 bytes (256 bits)
- Store it in a secure secret manager (AWS Secrets Manager, HashiCorp Vault, etc.)
- Never commit encryption keys to version control
- Rotate encryption keys periodically (requires re-encrypting all API keys)
Encryption Flow
- Application receives plaintext API key (e.g., from admin input or external API)
- Generate random 12-byte nonce
- Encrypt using AES-256-GCM:
- Key: From environment variable
- Nonce: Randomly generated
- Plaintext: The API key
- Associated Data: Organization ID (prevents key substitution attacks)
- Concatenate:
[nonce][ciphertext][auth_tag] - Store as BYTEA in
api_key_encryptedcolumn
Decryption Flow
- Retrieve encrypted data from database
- Extract components:
- First 12 bytes = nonce
- Last 16 bytes = auth tag
- Middle bytes = ciphertext
- Decrypt using AES-256-GCM:
- Key: From environment variable
- Nonce: Extracted from data
- Ciphertext: Extracted from data
- Auth Tag: Extracted from data
- Associated Data: Organization ID
- Verify auth tag (automatic in GCM mode)
- Return plaintext API key
If the auth tag verification fails, decryption is aborted (indicates tampering).
Generation
Creating a New API Key Integration
- Admin obtains API key from external service
- Admin creates integration via UI or API:bash
POST /v1/organizations/1/integrations Content-Type: application/json { "title": "Service Production", "service": "service_name", "api_key": "sk_live_abc123def456ghi789jkl012mno345pqr678" } - Backend encrypts the key and stores it:go
encrypted, err := crypto.EncryptAPIKey(orgID, apiKey) if err != nil { return err } integration := &models.OrganizationIntegration{ OrganizationID: orgID, Title: "Service Production", Service: "service_name", APIKeyEncrypted: encrypted, } db.Create(integration)
Supported Services
Currently supported integration services (enum integration_service):
- (none defined yet — extend the enum as integrations are added)
To add a new service:
- Add to the
integration_serviceenum in the database - Update application code to handle the new service
- Document the service-specific API key format and requirements
Rotation
Why Rotate API Keys?
- Security best practice - Regular rotation limits exposure window
- Compliance requirements - Some regulations require periodic key rotation
- Breach response - Immediate rotation if a key is compromised
- Staff changes - Rotate when admin access changes
Rotation Process
- Generate new API key in external service
- Update integration via API:bash
PUT /v1/organizations/1/integrations/5 Content-Type: application/json { "api_key": "sk_live_NEW_KEY_HERE" } - Backend re-encrypts and stores new key
- Test integration to ensure new key works
- Revoke old key in external service
- Audit log records the rotation event
Zero-Downtime Rotation
For critical integrations, use a dual-key approach:
- Create second integration with new API key
- Update application to try both keys (fallback logic)
- Monitor to ensure new key works
- Delete old integration after confirmation
- Revoke old key in external service
Usage in External Integrations
Retrieving API Keys
To use an API key for making external API calls:
// Get integration by organization and service
integration, err := db.GetOrganizationIntegration(orgID, "service_name")
if err != nil {
return err
}
// Decrypt the API key
apiKey, err := crypto.DecryptAPIKey(orgID, integration.APIKeyEncrypted)
if err != nil {
return err
}
// Use the API key in HTTP requests
client := externalservice.NewClient(apiKey)
response, err := client.DoSomething()Caching Decrypted Keys
DO NOT cache decrypted API keys in memory or on disk. Always decrypt on-demand:
- Security: Limits exposure if memory is dumped or process crashes
- Rotation: Ensures new keys are used immediately
- Compliance: Reduces plaintext key lifetime
If performance is a concern, cache only for the duration of a single request context.
Rate Limiting
The API endpoint for retrieving decrypted keys is rate limited:
GET /v1/organizations/{id}/api-keys- Max 10 requests per minute per organization
- Max 100 requests per hour per organization
This prevents:
- Brute force attacks
- Excessive decryption load
- Accidental loops in application code
Security Best Practices
Access Control
- Admin-only access to API keys endpoint
- RLS policies enforce organization boundaries
- Audit logging for all API key operations
- Rate limiting prevents abuse
Encryption Key Management
- Store encryption key in secret manager (AWS Secrets Manager, Vault)
- Rotate encryption key annually (requires re-encrypting all keys)
- Use different keys per environment (dev, staging, production)
- Never log encryption keys or decrypted API keys
API Key Hygiene
- Use service-specific keys when possible (not master keys)
- Rotate keys every 90 days or per compliance requirements
- Revoke keys immediately when admin access is removed
- Monitor API key usage in external services for anomalies
Audit Logging
All API key operations are logged to audit_log:
| Action | Description | Logged Fields |
|---|---|---|
integration.created | New API key added | organization_id, service, admin_user_id |
integration.updated | API key rotated | organization_id, service, admin_user_id |
integration.deleted | API key removed | organization_id, service, admin_user_id |
integration.viewed | API key decrypted via API | organization_id, service, admin_user_id, ip_address |
Example audit log entry:
{
"id": 12345,
"organization_id": 1,
"user_id": 42,
"action": "integration.viewed",
"entity_type": "organization_integration",
"entity_id": 5,
"changes": {
"service": "service_name",
"title": "Service Production"
},
"ip_address": "192.168.1.100",
"user_agent": "Mozilla/5.0...",
"request_path": "GET /v1/organizations/1/api-keys",
"created_at": "2025-01-15T14:22:00Z"
}Infrastructure Encryption
In addition to application-level encryption, API keys benefit from:
- Encryption at rest - AWS RDS encrypts all database storage
- Encryption in transit - TLS 1.2+ for all database connections
- Encrypted backups - RDS snapshots are encrypted
- Encrypted replication - Replica databases use encrypted connections
This provides defense in depth: even if one layer fails, keys remain protected.
Compliance
HIPAA
API keys for healthcare integrations must:
- Be encrypted at rest (✓ AES-256-GCM)
- Be encrypted in transit (✓ TLS)
- Have access logging (✓ audit_log)
- Support key rotation (✓)
- Have admin-only access (✓ RLS policies)
GDPR
API keys are not considered personal data, but access logs are:
- Audit logs containing user_id are subject to GDPR
- Export user data includes their API key access history
- Anonymize user_id when user requests deletion
Troubleshooting
Decryption Fails
Symptoms: Error decrypting API key, invalid auth tag
Possible Causes:
- Encryption key changed (environment variable updated)
- Data corrupted in database
- Associated data mismatch (organization_id changed)
Resolution:
- Check encryption key environment variable
- Re-encrypt API key using correct key
- Verify organization_id hasn't changed
Integration Not Working
Symptoms: External API calls fail with authentication errors
Possible Causes:
- API key expired in external service
- API key revoked in external service
- Wrong service selected in integration
Resolution:
- Verify API key in external service dashboard
- Rotate API key to new valid key
- Check service enum matches external service
Rate Limit Errors
Symptoms: 429 rate_limited when accessing /v1/organizations/{id}/api-keys
Possible Causes:
- Application polling API keys too frequently
- Admin repeatedly accessing keys in UI
- Bug causing infinite loop
Resolution:
- Cache keys in request context (not globally)
- Reduce polling frequency
- Check application logs for loops
Migration Guide
Migrating from Plaintext Keys
If you're migrating from plaintext API key storage:
- Add encryption key to environment variables
- Create migration script:go
func MigrateAPIKeys() error { var integrations []OrganizationIntegration db.Find(&integrations) for _, integration := range integrations { // Read plaintext key from old column plaintext := integration.APIKeyPlaintext // Encrypt using new encryption function encrypted, err := crypto.EncryptAPIKey( integration.OrganizationID, plaintext, ) if err != nil { return err } // Save encrypted key, clear plaintext integration.APIKeyEncrypted = encrypted integration.APIKeyPlaintext = "" db.Save(&integration) } return nil } - Run migration in production
- Verify all keys still work
- Drop old column from database
Re-encrypting with New Key
If you need to rotate the encryption key itself:
- Generate new 256-bit encryption key
- Store in environment as
API_KEY_ENCRYPTION_KEY_NEW - Create re-encryption script:go
func ReencryptAPIKeys() error { oldKey := os.Getenv("API_KEY_ENCRYPTION_KEY") newKey := os.Getenv("API_KEY_ENCRYPTION_KEY_NEW") var integrations []OrganizationIntegration db.Find(&integrations) for _, integration := range integrations { // Decrypt with old key plaintext, err := crypto.DecryptAPIKeyWithKey( oldKey, integration.OrganizationID, integration.APIKeyEncrypted, ) if err != nil { return err } // Re-encrypt with new key encrypted, err := crypto.EncryptAPIKeyWithKey( newKey, integration.OrganizationID, plaintext, ) if err != nil { return err } // Save integration.APIKeyEncrypted = encrypted db.Save(&integration) } return nil } - Run re-encryption during maintenance window
- Replace old key with new key in environment
- Verify all integrations still work
- Remove
API_KEY_ENCRYPTION_KEY_NEWfrom environment