How to Manage Multiple API Keys Securely
·APIScout Team
api keyssecuritysecrets managementbest practicesauthentication
How to Manage Multiple API Keys Securely
A typical production app uses 5-15 different API keys. Stripe, Resend, Clerk, OpenAI, Cloudflare, analytics, monitoring — each with its own key that grants access to sensitive operations. One leaked key can compromise your users, your data, and your bank account.
The API Key Landscape
How Many Keys Do You Actually Have?
Typical SaaS app API keys:
Payment: Stripe secret + publishable key
Auth: Clerk secret + publishable key
Email: Resend API key
AI: OpenAI API key + Anthropic API key
Analytics: PostHog project key
Storage: AWS access key + secret key
Database: Connection string (with credentials)
Monitoring: Sentry DSN
CDN: Cloudflare API token
Deployment: Vercel token
Total: 12+ secrets to manage
Key Types
| Type | Example | Exposure Risk | If Leaked |
|---|---|---|---|
| Secret key | sk_live_... | Server only | Full API access, financial damage |
| Publishable key | pk_live_... | Client-safe | Limited operations, low risk |
| API token | ghp_... | Server only | Account access |
| Connection string | postgres://user:pass@... | Server only | Database access |
| Webhook secret | whsec_... | Server only | Webhook forgery |
| JWT secret | Random string | Server only | Token forgery |
Rule 1: Never Commit Keys to Git
The #1 source of key leaks: committing to version control.
# .gitignore — MUST include
.env
.env.local
.env.production
.env.*.local
*.pem
*.key
credentials.json
# Pre-commit hook to catch secrets
# Install: npx husky add .husky/pre-commit "npx secretlint '**/*'"
# Or use git-secrets
git secrets --install
git secrets --register-aws # Catches AWS keys
# Check for accidentally committed secrets
git log --all --diff-filter=A -- '*.env' '.env*'
git log --all -p -- . | grep -i "sk_live\|secret_key\|password"
# If you find a committed secret:
# 1. Rotate the key IMMEDIATELY (old key is compromised)
# 2. Remove from history (git filter-repo)
# 3. Force push (coordinate with team)
Rule 2: Environment Variables Only
// ❌ Never hard-code keys
const stripe = new Stripe('sk_live_abc123...'); // NO!
// ❌ Never put keys in client-side code
const openai = new OpenAI({
apiKey: 'sk-abc123', // Visible in browser devtools
});
// ✅ Environment variables
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// ✅ With validation
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
const config = {
stripe: {
secretKey: requireEnv('STRIPE_SECRET_KEY'),
webhookSecret: requireEnv('STRIPE_WEBHOOK_SECRET'),
},
resend: {
apiKey: requireEnv('RESEND_API_KEY'),
},
openai: {
apiKey: requireEnv('OPENAI_API_KEY'),
},
};
Structured Environment Validation
import { z } from 'zod';
const EnvSchema = z.object({
// Required
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'),
RESEND_API_KEY: z.string().startsWith('re_'),
CLERK_SECRET_KEY: z.string().startsWith('sk_'),
DATABASE_URL: z.string().startsWith('postgres://'),
// Optional with defaults
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});
// Validate at startup — fail fast if keys are missing
export const env = EnvSchema.parse(process.env);
Rule 3: Use Secrets Managers
For production, don't rely on .env files:
| Solution | Best For | How It Works |
|---|---|---|
| Vercel Environment Variables | Vercel deployments | Encrypted, per-environment, auto-injected |
| AWS Secrets Manager | AWS infrastructure | Encrypted, rotation support, IAM-controlled |
| Google Secret Manager | GCP infrastructure | Encrypted, versioned, IAM-controlled |
| Doppler | Multi-platform | Universal secrets manager, syncs everywhere |
| 1Password CLI | Small teams | Load secrets from vault into env |
| HashiCorp Vault | Enterprise | Full secrets management, dynamic secrets |
| Infisical | Open-source alternative | Self-hostable, team sync |
# Doppler — sync secrets across environments
doppler setup
doppler run -- npm start
# All env vars injected from Doppler, never stored locally
# 1Password CLI
op run --env-file=.env.1password -- npm start
# Secrets pulled from 1Password vault at runtime
# AWS Secrets Manager
aws secretsmanager get-secret-value --secret-id prod/api-keys
Rule 4: Scope Keys Minimally
Never use admin keys when read-only will do:
// ❌ Using full-access key everywhere
const cloudflare = new Cloudflare({
apiToken: process.env.CF_API_TOKEN, // Has permission to delete zones!
});
// ✅ Scoped API tokens
const CF_TOKENS = {
dns: process.env.CF_DNS_TOKEN, // Edit DNS only
cache: process.env.CF_CACHE_TOKEN, // Purge cache only
analytics: process.env.CF_ANALYTICS_TOKEN, // Read analytics only
};
Key Scoping by Provider
| Provider | Scoping Options |
|---|---|
| Stripe | Restricted keys with specific permissions |
| Cloudflare | API tokens with per-resource permissions |
| GitHub | Fine-grained PATs with repo/scope selection |
| AWS | IAM policies with resource-level permissions |
| OpenAI | Project-level keys, usage limits |
# Stripe — create restricted key
# Dashboard → API Keys → Create restricted key
# Set permissions:
# Charges: Read
# Customers: Write
# Refunds: None
# Result: Key can create customers and read charges, but can't issue refunds
Rule 5: Rotate Keys Regularly
// Key rotation strategy
interface KeyRotation {
schedule: string;
how: string;
}
const rotationPolicies: Record<string, KeyRotation> = {
'stripe': {
schedule: 'Quarterly',
how: 'Generate new key in dashboard → update env → verify → revoke old key',
},
'database': {
schedule: 'Monthly',
how: 'Create new credentials → update connection string → verify → drop old user',
},
'jwt_secret': {
schedule: 'Annually',
how: 'Generate new secret → support both old+new during transition → remove old',
},
'webhook_secrets': {
schedule: 'Annually',
how: 'Add new secret → verify webhook signatures with both → remove old',
},
};
Zero-Downtime Key Rotation
// Support multiple valid keys during rotation
function verifyWebhookSignature(
payload: string,
signature: string,
secrets: string[]
): boolean {
// Try each secret — old and new
return secrets.some(secret => {
const expected = createHmac('sha256', secret)
.update(payload)
.digest('hex');
return timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
});
}
// During rotation:
const WEBHOOK_SECRETS = [
process.env.WEBHOOK_SECRET_NEW!, // New key (primary)
process.env.WEBHOOK_SECRET_OLD!, // Old key (still valid during transition)
];
const isValid = verifyWebhookSignature(payload, sig, WEBHOOK_SECRETS);
Rule 6: Monitor Key Usage
// Track which keys are used and how
// Most providers offer usage dashboards
// Stripe: Dashboard → API Keys → shows last used date
// OpenAI: Dashboard → Usage → per-key breakdown
// AWS: CloudTrail logs all API key usage
// Custom monitoring:
class APIKeyMonitor {
async logUsage(keyName: string, endpoint: string, status: number) {
await analytics.track('api_key_usage', {
key: keyName,
endpoint,
status,
timestamp: new Date().toISOString(),
});
// Alert on suspicious activity
if (status === 401) {
await alert(`API key ${keyName} returned 401 — may be expired or compromised`);
}
}
}
Rule 7: Client-Side Key Safety
// ONLY publishable/public keys on the client side
// ✅ Safe for client
const NEXT_PUBLIC_STRIPE_KEY = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY;
const NEXT_PUBLIC_CLERK_KEY = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY;
const NEXT_PUBLIC_POSTHOG_KEY = process.env.NEXT_PUBLIC_POSTHOG_KEY;
// ❌ NEVER on client
// STRIPE_SECRET_KEY → server only
// OPENAI_API_KEY → server only
// DATABASE_URL → server only
// Next.js enforces this:
// NEXT_PUBLIC_ prefix → available on client
// No prefix → server only (won't be in browser bundle)
// For APIs without publishable keys, proxy through your server
// ❌ Don't do this
const response = await fetch('https://api.openai.com/v1/chat/completions', {
headers: { 'Authorization': `Bearer ${OPENAI_KEY}` }, // Key in browser!
});
// ✅ Proxy through your API route
const response = await fetch('/api/ai/chat', {
method: 'POST',
body: JSON.stringify({ message: 'Hello' }),
});
// Your API route handles the key securely
// app/api/ai/chat/route.ts
export async function POST(req: Request) {
const { message } = await req.json();
const result = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [{ role: 'user', content: message }],
});
return Response.json(result);
}
Emergency Response: Key Leak
⚠️ Key leaked (committed to GitHub, in logs, shared publicly):
1. IMMEDIATELY rotate the key (new key from provider dashboard)
2. Update environment variables in production
3. Deploy with new key
4. Revoke the old key
5. Check audit logs for unauthorized usage
6. If financial API (Stripe, etc.): check for fraudulent transactions
7. Post-mortem: how did it leak? Prevent recurrence
Timeline: Steps 1-4 should take < 15 minutes
Common Mistakes
| Mistake | Impact | Fix |
|---|---|---|
Committing .env to Git | Keys in version history forever | .gitignore, pre-commit hooks |
| Using secret keys in client code | Keys visible to anyone | Publishable keys only on client, proxy for secret APIs |
| Same key for all environments | Test actions affect production | Separate keys per environment |
| Admin/full-access keys everywhere | Blast radius if compromised | Scoped keys with minimal permissions |
| Never rotating keys | Longer exposure if leaked | Rotate quarterly minimum |
| No secret validation at startup | Runtime errors from missing keys | Validate with Zod on startup |
| Sharing keys via Slack/email | Keys in chat history forever | Use secrets manager, share via 1Password |
Find APIs with the best key management features on APIScout — key scoping, rotation support, and security best practices for every provider.