API PlaybookSecurity
SecurityIntermediate4 min

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_xxx

A 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:

SystemRetentionWho can see it
Server access logs30-90 daysOps team, log aggregators
CDN edge logs7-30 daysCDN provider, analytics
Browser historyIndefiniteAnyone with device access
Referer headerSent to any linked pageThird-party sites
Proxy logsVariesCorporate proxy admins
Analytics toolsIndefiniteMarketing, product teams
Monitoring dashboardsVariesError 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:

PrefixMeaning
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 .env in .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.