Secrets Management
Prevent hardcoded API keys, passwords, and tokens from leaking into source control. This is Finding #4 from Malaysian PDPA court cases -- secrets in public repos are treated as gross negligence.
The Rule
NEVER commit secrets to source code. ALWAYS use environment variables or secret managers.
Detection Patterns
What Counts as a Secret
- API keys (Stripe, OpenAI, AWS, etc.)
- Database connection strings with passwords
- JWT signing keys
- OAuth client secrets
- Webhook signing secrets
- Private keys (RSA, SSH)
- Service account credentials
Search Patterns for Auditing
# Hardcoded strings that look like secrets
(api[_-]?key|secret|password|token|credential|private[_-]?key)\s*[:=]\s*["'][^"']{8,}["']
# AWS keys
AKIA[0-9A-Z]{16}
# Generic high-entropy strings in assignments
(SECRET|KEY|TOKEN|PASSWORD|CREDENTIAL)\s*=\s*["'][A-Za-z0-9+/=]{20,}["']
# Connection strings with embedded passwords
(mongodb|postgres|mysql|redis):\/\/[^:]+:[^@]+@
Correct Setup
1. Create .env File (NEVER committed)
# .env (local development only)
DATABASE_URL=postgres://user:pass@localhost:5432/mydb
STRIPE_SECRET_KEY=sk_test_abc123
JWT_SECRET=your-random-64-char-string
OPENAI_API_KEY=sk-proj-abc123
2. Create .env.example (committed, no real values)
# .env.example (committed to repo as documentation)
DATABASE_URL=postgres://user:password@localhost:5432/dbname
STRIPE_SECRET_KEY=sk_test_xxx
JWT_SECRET=generate-with-openssl-rand-base64-48
OPENAI_API_KEY=sk-proj-xxx
3. Add to .gitignore
# Secrets - NEVER commit these
.env
.env.local
.env.production
.env.*.local
*.pem
*.key
service-account.json
credentials.json
Framework-Specific Patterns
Node.js / TypeScript
BAD:
const stripe = new Stripe('sk_live_abc123xyz');
const jwt = sign(payload, 'my-super-secret-key');
const db = new Pool({ password: 'production-password-123' });
GOOD:
import { env } from 'process';
const stripe = new Stripe(env.STRIPE_SECRET_KEY!);
const jwt = sign(payload, env.JWT_SECRET!);
const db = new Pool({ connectionString: env.DATABASE_URL });
// Validate at startup
const required = ['STRIPE_SECRET_KEY', 'JWT_SECRET', 'DATABASE_URL'];
for (const key of required) {
if (!env[key]) throw new Error(`Missing required env var: ${key}`);
}
Cloudflare Workers
BAD:
const API_KEY = 'sk-abc123';
export default {
async fetch(request: Request) {
return fetch('https://api.openai.com/v1/chat', {
headers: { Authorization: `Bearer ${API_KEY}` }
});
}
};
GOOD:
// wrangler.toml - declare bindings
// [vars]
// PUBLIC_URL = "https://myapp.com"
//
// Secrets added via: npx wrangler secret put OPENAI_API_KEY
interface Env {
OPENAI_API_KEY: string;
PUBLIC_URL: string;
}
export default {
async fetch(request: Request, env: Env) {
return fetch('https://api.openai.com/v1/chat', {
headers: { Authorization: `Bearer ${env.OPENAI_API_KEY}` }
});
}
};
Laravel / PHP
BAD:
'stripe' => [
'secret' => 'sk_live_abc123xyz',
],
GOOD:
// config/services.php
'stripe' => [
'secret' => env('STRIPE_SECRET_KEY'),
],
// Validate in AppServiceProvider::boot()
$required = ['STRIPE_SECRET_KEY', 'DB_PASSWORD', 'APP_KEY'];
foreach ($required as $key) {
if (empty(env($key))) {
throw new \RuntimeException("Missing env: {$key}");
}
}
Python
BAD:
OPENAI_API_KEY = "sk-proj-abc123"
DATABASE_URL = "postgres://admin:password123@prod-db:5432/app"
GOOD:
import os
from dotenv import load_dotenv
load_dotenv()
OPENAI_API_KEY = os.environ["OPENAI_API_KEY"]
DATABASE_URL = os.environ["DATABASE_URL"]
# Pydantic settings (recommended)
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
openai_api_key: str
database_url: str
jwt_secret: str
class Config:
env_file = ".env"
settings = Settings()
Secret Rotation Strategy
When to Rotate
- Immediately if a secret was ever committed to git (even if removed later)
- Immediately if a team member leaves
- Every 90 days for production secrets (best practice)
- After any security incident
How to Rotate Without Downtime
// Support both old and new key during rotation window
const VALID_API_KEYS = [
env.API_KEY_CURRENT,
env.API_KEY_PREVIOUS, // Remove after 24h
].filter(Boolean);
function validateApiKey(key: string): boolean {
return VALID_API_KEYS.includes(key);
}
Emergency: Secret Was Committed
If a secret was pushed to git, even briefly:
- Rotate the secret immediately -- generate a new one from the provider
- Revoke the old secret -- don't just rotate, explicitly revoke
- Check for unauthorized usage -- review access logs
- Clean git history (optional, secret is already compromised):
# Only if repo is private and you caught it fast git filter-branch --force --index-filter \ "git rm --cached --ignore-unmatch .env" \ --prune-empty --tag-name-filter cat -- --all - Add to .gitignore to prevent recurrence
CI/CD Secret Injection
GitHub Actions
# Use repository secrets, never hardcode
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
Cloudflare Workers (Wrangler)
# Set secrets via CLI (never in wrangler.toml)
npx wrangler secret put DATABASE_URL
npx wrangler secret put STRIPE_SECRET_KEY
PDPA 2024 Consequence
Seksyen 5(1) dan 5(2) PDPA 2010 (Pindaan 2024):
- Denda sehingga RM1,000,000
- Penjara sehingga 3 tahun
Hardcoded secrets in a public repository is considered kecuaian melampau (gross negligence). If those secrets provide access to personal data, the developer is criminally liable.