API Key Management: Best Practices to Prevent Leaks and Misuse
API keys are the most commonly leaked credentials in software development. Learn how to generate, scope, rotate, store, and monitor API keys to protect your infrastructure.
API keys are the workhorse of developer authentication. They're simple, stateless, and easy to integrate. They're also the most commonly exposed credential in software development. Studies of public GitHub repositories regularly find thousands of valid API keys committed to code — AWS credentials, Stripe keys, Twilio tokens, database passwords — each one a potential breach waiting to happen.
This guide covers every stage of the API key lifecycle: generation, scoping, storage, rotation, monitoring, and revocation.
Why API Keys Are High-Risk
Unlike user passwords, API keys are:
- Long-lived by default — they don't expire unless you revoke them.
- Highly privileged — many developers generate admin-scoped keys out of convenience.
- Widely distributed — shared between developers, CI/CD systems, and third-party services.
- Easy to leak — a single
git commitof a config file exposes them permanently (git history isn't deleted by removing the file).
The consequences of a leaked API key range from unexpected bills to full account compromise to data exfiltration.
Key Generation: Start With Strong Entropy
Good API keys are:
- Sufficiently long: At minimum 32 bytes of entropy (256 bits). This produces 43-44 characters in Base64url encoding.
- Cryptographically random: Generated with a CSPRNG, not
Math.random(). - Prefixed: A vendor-specific prefix makes keys easy to identify in logs, grep searches, and secret scanning tools.
const crypto = require('crypto');
function generateApiKey(prefix = 'sk') {
const randomBytes = crypto.randomBytes(32);
const keyBody = randomBytes.toString('base64url');
return `${prefix}_${keyBody}`;
// Example: sk_K3pZ8mRqLwXvY2nQ...
}
// Store only the hashed version
async function createApiKey(userId, name, scopes) {
const rawKey = generateApiKey('sk');
const hashedKey = crypto.createHash('sha256').update(rawKey).digest('hex');
await db.apiKeys.create({
userId,
name,
scopes,
keyHash: hashedKey,
prefix: rawKey.substring(0, 7), // Store first 7 chars for identification
createdAt: new Date(),
lastUsedAt: null,
});
// Return the raw key ONCE — never store it
return rawKey;
}
Only show the full key once (at creation time). After that, store only the hash. This mirrors how password storage works: you can verify a key by hashing the input and comparing, but you can never reconstruct the original.
Scope API Keys to Least Privilege
Giving every API key full admin access is the most common API key mistake. If any single key leaks, the attacker has complete control.
Design your API key system with granular scopes:
// Define available scopes
const SCOPES = {
'repos:read': 'Read repository data',
'repos:write': 'Create and update repositories',
'scans:read': 'Read scan results',
'scans:trigger': 'Trigger new scans',
'settings:read': 'Read organization settings',
'settings:write': 'Modify organization settings',
'admin': 'Full administrative access',
} as const;
type Scope = keyof typeof SCOPES;
// Check scope on every request
function requireScope(requiredScope: Scope) {
return (req, res, next) => {
const apiKey = req.apiKey; // Populated by earlier auth middleware
if (!apiKey.scopes.includes(requiredScope) && !apiKey.scopes.includes('admin')) {
return res.status(403).json({
error: `This API key does not have the '${requiredScope}' scope`,
});
}
next();
};
}
// Route example
router.get('/scans', authenticate, requireScope('scans:read'), getScanResults);
router.post('/scans', authenticate, requireScope('scans:trigger'), triggerScan);
Issue keys with only the scopes needed for each integration:
- CI/CD pipeline?
scans:triggerandscans:readonly. - Read-only dashboard integration?
repos:readandscans:readonly. - Billing webhook? A separate key scoped to
webhooks:billing.
Per-Environment Keys
Never use the same API key in development, staging, and production. Per-environment keys:
- Limit blast radius if a dev or staging key leaks.
- Allow you to revoke a key without disrupting production.
- Enable environment-specific rate limits and monitoring.
# .env.development
API_KEY=sk_dev_...
# .env.staging
API_KEY=sk_staging_...
# .env.production (loaded from secrets manager, not a file)
# API_KEY is injected from AWS Secrets Manager / HashiCorp Vault
Use naming conventions in your key management system to track which environment each key belongs to.
Storing Keys in Environment Variables
The most basic but most violated rule: never hardcode API keys in source code.
What goes wrong:
// NEVER do this
const stripe = new Stripe('sk_live_AbCdEfGhIjKlMnOp...'); // Committed to git
Even if you catch this and remove the key, git history preserves it. Tools like truffleHog, gitleaks, and GitHub's secret scanning will find it.
The right approach:
// Read from environment
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? '');
// Validate at startup
if (!process.env.STRIPE_SECRET_KEY) {
throw new Error('STRIPE_SECRET_KEY environment variable is required');
}
For local development, use .env files managed by dotenv, but:
- Add
.env*to.gitignore(except.env.example). - Create a
.env.examplewith placeholder values to document required variables. - Never commit
.env.localor.env.production.
# .gitignore
.env
.env.local
.env.production
.env.*.local
# .env.example (safe to commit)
STRIPE_SECRET_KEY=your_stripe_secret_key_here
DATABASE_URL=your_database_url_here
For production, use a secrets manager:
- AWS Secrets Manager or AWS Systems Manager Parameter Store
- HashiCorp Vault
- GCP Secret Manager
- Azure Key Vault
- Doppler or Infisical (developer-friendly options)
Detecting Leaked Keys
Keys get leaked in multiple ways beyond git commits:
- Pasted into Slack messages, Notion docs, or Jira tickets
- Exposed in application logs or error messages
- Included in support tickets or screenshots
- Leaked through third-party tools that log API requests
- Published in client-side JavaScript bundles
Detection approaches:
1. Use vendor secret scanning: GitHub, GitLab, and Bitbucket have built-in secret scanning that alerts on committed credentials. Enable it for all repositories.
2. Pre-commit hooks with gitleaks:
# Install gitleaks
brew install gitleaks
# Install as a pre-commit hook
cat > .git/hooks/pre-commit << 'EOF'
#!/bin/bash
gitleaks protect --staged --redact --config .gitleaks.toml
EOF
chmod +x .git/hooks/pre-commit
3. Monitor for use of revoked keys: Log every API authentication attempt. Alert on attempts to use revoked or expired keys — it may indicate an attacker testing stolen credentials.
4. Audit public repositories: Periodically search GitHub and other public platforms for your organization's credentials using the GitHub code search API or tools like truffleHog.
Key Rotation
API keys should be rotated on a regular schedule and immediately when:
- An employee with access leaves the company.
- A security incident occurs or is suspected.
- A third-party service that stored the key is breached.
- The key appears in any log, commit, or communication channel.
Build your systems to support rotation without downtime:
// Support multiple active keys during rotation window
async function authenticateApiKey(rawKey: string) {
const hashedKey = crypto.createHash('sha256').update(rawKey).digest('hex');
const apiKey = await db.apiKeys.findOne({
keyHash: hashedKey,
revokedAt: null, // Not revoked
expiresAt: { $gt: new Date() }, // Not expired
});
if (!apiKey) {
return null;
}
// Update last used timestamp (async, don't await)
db.apiKeys.updateOne(
{ _id: apiKey._id },
{ $set: { lastUsedAt: new Date() } }
).catch(console.error);
return apiKey;
}
The rotation process:
- Issue a new key alongside the existing one.
- Update all consumers to use the new key (deploy to each service/environment).
- Monitor that the old key is no longer being used (
lastUsedAtstops updating). - Revoke the old key.
Audit Logging
Every API key usage should be logged with enough context to detect anomalies:
// Log API key usage
async function logApiKeyUsage(apiKey, req) {
await auditLog.create({
keyId: apiKey._id,
keyPrefix: apiKey.prefix,
userId: apiKey.userId,
action: `${req.method} ${req.path}`,
ip: req.ip,
userAgent: req.headers['user-agent'],
timestamp: new Date(),
responseStatus: null, // Update after response
});
}
What to alert on:
- Usage from unexpected IPs or geographic regions
- Unusual request volumes (rate limiting bypass attempts)
- Use of revoked or expired keys (possible attacker probe)
- Usage at unusual hours for a given key
- Keys that haven't been used in 90+ days (candidates for revocation)
Revocation
Your API key system must support immediate revocation. When a leak is suspected, every second the key remains active is a second of potential damage.
// Immediate revocation
async function revokeApiKey(keyId: string, reason: string, revokedBy: string) {
await db.apiKeys.updateOne(
{ _id: keyId },
{
$set: {
revokedAt: new Date(),
revocationReason: reason,
revokedBy,
}
}
);
// If you cache API key lookups (e.g., in Redis), invalidate the cache
await cache.del(`apikey:${keyId}`);
await auditLog.create({
action: 'api_key_revoked',
keyId,
reason,
revokedBy,
timestamp: new Date(),
});
}
Don't delete revoked keys — keep them with a revokedAt timestamp so you have a historical audit trail.
Summary Checklist
- Keys use 32+ bytes of cryptographic randomness
- Keys have vendor-specific prefixes for easy identification
- Only the hash is stored server-side
- Keys are scoped to least privilege
- Separate keys per environment (dev/staging/prod)
- Keys are stored in environment variables or secrets manager
-
.envfiles are in.gitignore - Pre-commit secret scanning is enabled
- Rotation process is documented and tested
- All usage is logged with IP, timestamp, and endpoint
- Immediate revocation is supported
- Regular audits for unused or over-privileged keys
API key security isn't glamorous, but the consequences of getting it wrong are severe and well-documented. Treating API keys with the same rigor as passwords — hashed storage, least-privilege scoping, rotation, and audit logging — eliminates the most common attack vectors.