Secrets Management
API keys in URLs end up in logs — always
In a nutshell
API keys, database passwords, and webhook secrets are credentials that grant access to your systems. If they end up in the wrong place -- a URL that gets logged, a git commit, a browser history tab -- anyone who finds them can impersonate your app. Secrets management is about transmitting them safely (in headers, not URLs), storing them securely (in vaults, not code), and rotating them regularly so a leak doesn't mean permanent access.
The situation
Your API documentation shows this example:
GET /api/data?key=sk_live_xxxA developer copies it. The key ends up in the URL. That URL appears in Nginx access logs, CDN edge logs, browser history, the Referer header when the user clicks a link, and your analytics platform. Six months later, someone searches the log aggregator and finds 47 live API keys in plaintext.
This isn't hypothetical. It's the most common secret leak pattern in production.
The anti-pattern: secrets in URLs
# Every system that touches this request logs the full URL
curl "https://api.example.com/api/data?key=YOUR_SECRET_KEY"Where that key now lives:
| System | Retention | Who can see it |
|---|---|---|
| Server access logs | 30-90 days | Ops team, log aggregators |
| CDN edge logs | 7-30 days | CDN provider, analytics |
| Browser history | Indefinite | Anyone with device access |
| Referer header | Sent to any linked page | Third-party sites |
| Proxy logs | Varies | Corporate proxy admins |
| Analytics tools | Indefinite | Marketing, product teams |
| Monitoring dashboards | Varies | Error tracking services |
Query parameters are not secret
HTTPS encrypts the URL in transit, but the URL is logged in plaintext at every layer: the web server, the reverse proxy, the CDN, the browser, and every monitoring tool. Query parameters are metadata, not a secure transport channel.
The fix: secrets in headers
# The key is in a header — not logged by default in most systems
curl https://api.example.com/api/data \
-H "Authorization: Bearer <your-secret-key>"Or with a custom header:
curl https://api.example.com/api/data \
-H "X-API-Key: <your-secret-key>"Most web servers, CDNs, and monitoring tools log the URL and status code by default. They do not log request headers by default. Moving the secret from the URL to a header removes it from the default log surface.
Key design: prefix, scope, and expiry
Well-designed API keys carry metadata that makes them manageable at scale.
// Key metadata (stored server-side, never exposed in full)
{
"key_id": "key_2a9f3b",
"prefix": "sk_live_",
"name": "Production - Order Service",
"created_at": "2026-01-15T10:00:00Z",
"expires_at": "2026-07-15T10:00:00Z",
"last_used_at": "2026-04-13T08:42:11Z",
"scopes": ["orders:read", "orders:write"],
"allowed_ips": ["10.0.1.0/24"],
"created_by": "usr_8a3f",
"environment": "production"
}Key naming conventions
Use prefixes to make keys self-documenting and prevent environment mix-ups:
| Prefix | Meaning |
|---|---|
sk_live_ | Secret key, production |
sk_test_ | Secret key, test/sandbox |
pk_live_ | Public key, production (safe to expose) |
pk_test_ | Public key, test/sandbox |
whsec_ | Webhook signing secret |
The convention is simple — when you see a key starting with sk_test_, you know immediately it's a sandbox key. When a key has no prefix, you can't tell whether revoking it will break production. Stripe, Twilio, and every mature API platform uses this pattern. Copy them.
Prefix discipline saves incidents
Key prefixes make leaked credentials instantly identifiable. Automated scanners (GitHub secret scanning, GitGuardian, TruffleHog) use these prefixes to detect and alert on leaked keys within minutes. Without a prefix, a leaked key looks like any random string.
Key rotation
Static keys that never change are ticking time bombs. Build rotation into the design.
Overlap window rotation
// Step 1: Generate new key (both keys active during overlap)
{
"keys": [
{
"key_id": "key_old_1a2b",
"status": "active",
"expires_at": "2026-04-20T00:00:00Z"
},
{
"key_id": "key_new_3c4d",
"status": "active",
"created_at": "2026-04-13T00:00:00Z"
}
]
}# Step 2: Update all clients to use the new key
# Step 3: Verify no traffic uses the old key
curl https://api.example.com/admin/keys/key_old_1a2b/usage{
"key_id": "key_old_1a2b",
"last_used_at": "2026-04-14T03:12:00Z",
"requests_last_24h": 0
}# Step 4: Revoke the old key
curl -X DELETE https://api.example.com/admin/keys/key_old_1a2b \
-H "Authorization: Bearer <admin-token>"{
"key_id": "key_old_1a2b",
"status": "revoked",
"revoked_at": "2026-04-15T10:00:00Z"
}The overlap window (both keys active simultaneously) lets you rotate without downtime. Never revoke the old key before confirming zero traffic.
Environment variables, not code
# .env file (never committed)
STRIPE_SECRET=<your-stripe-key>
DATABASE_URL=<your-connection-string>
WEBHOOK_SECRET=<your-webhook-secret># .gitignore (committed)
.env
.env.local
.env.production
*.pem
*.key# Application code reads from environment
import os
stripe_key = os.environ["STRIPE_SECRET"]If a secret appears in your source code, it appears in your git history — forever. Even if you remove it in the next commit, anyone with repo access can find it. Use environment variables, inject them at deploy time, and add secret files to .gitignore before they're created.
Git history is forever
Running git rm .env removes the file from the working tree. It does not remove it from history. Anyone can run git log --all --full-history -- .env and recover every version. If a secret was ever committed, consider it compromised and rotate immediately.
Vault pattern: centralized secret storage
For production systems, environment variables alone aren't enough. Use a vault — a dedicated secret store with access control, audit logging, and automatic rotation.
# Reading a secret from HashiCorp Vault
curl -H "X-Vault-Token: <vault-token>" \
https://vault.internal.example.com/v1/secret/data/api-keys/stripe{
"data": {
"data": {
"live_key": "<redacted>",
"webhook_signing": "<redacted>"
},
"metadata": {
"created_time": "2026-01-15T10:00:00Z",
"version": 3
}
}
}The vault provides:
- Access control — only authorized services can read specific secrets
- Audit trail — who accessed which secret, when
- Versioning — roll back to a previous secret version
- Auto-rotation — generate and rotate secrets on a schedule
- Dynamic secrets — generate short-lived database credentials on demand
Options: HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager, Azure Key Vault, Doppler, 1Password Secrets Automation.
Checklist: secrets management
- Are all secrets transmitted in headers, never in URLs?
- Do API keys have prefixes indicating environment and type?
- Are keys scoped to the minimum permissions needed?
- Is there a rotation process with an overlap window?
- Are secrets stored in a vault or environment variables, not in code?
- Is
.envin.gitignore? - Has the git history been checked for accidentally committed secrets?
That wraps up the security section. The attack surface is bigger than most teams realize — but the fixes are usually straightforward. Authentication, authorization, CORS, OWASP basics, and secrets hygiene cover the vast majority of real-world API security incidents.