Skip to main content

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

TypeExampleExposure RiskIf Leaked
Secret keysk_live_...Server onlyFull API access, financial damage
Publishable keypk_live_...Client-safeLimited operations, low risk
API tokenghp_...Server onlyAccount access
Connection stringpostgres://user:pass@...Server onlyDatabase access
Webhook secretwhsec_...Server onlyWebhook forgery
JWT secretRandom stringServer onlyToken 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:

SolutionBest ForHow It Works
Vercel Environment VariablesVercel deploymentsEncrypted, per-environment, auto-injected
AWS Secrets ManagerAWS infrastructureEncrypted, rotation support, IAM-controlled
Google Secret ManagerGCP infrastructureEncrypted, versioned, IAM-controlled
DopplerMulti-platformUniversal secrets manager, syncs everywhere
1Password CLISmall teamsLoad secrets from vault into env
HashiCorp VaultEnterpriseFull secrets management, dynamic secrets
InfisicalOpen-source alternativeSelf-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

ProviderScoping Options
StripeRestricted keys with specific permissions
CloudflareAPI tokens with per-resource permissions
GitHubFine-grained PATs with repo/scope selection
AWSIAM policies with resource-level permissions
OpenAIProject-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

MistakeImpactFix
Committing .env to GitKeys in version history forever.gitignore, pre-commit hooks
Using secret keys in client codeKeys visible to anyonePublishable keys only on client, proxy for secret APIs
Same key for all environmentsTest actions affect productionSeparate keys per environment
Admin/full-access keys everywhereBlast radius if compromisedScoped keys with minimal permissions
Never rotating keysLonger exposure if leakedRotate quarterly minimum
No secret validation at startupRuntime errors from missing keysValidate with Zod on startup
Sharing keys via Slack/emailKeys in chat history foreverUse 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.

Comments