You need to keep OpenAI API key secure from the moment you create it. A leaked key means unauthorized usage, unexpected bills, and potential data exposure if your requests contain sensitive context. This guide walks through the complete lifecycle: generation, storage, rotation, monitoring, and revocation. Each step includes verification commands so you can confirm the control actually works.
Step 1: Generate the key in a clean environment
Create the key on a machine you trust. Avoid CI runners, shared jump hosts, or browsers with extensions that log network traffic. If you must use a browser, open an incognito window, disable extensions, and verify the URL is https://platform.openai.com.
# Example: generate a key via the CLI (requires openai-python >= 1.0)
openai api keys.create --name "prod-inference-$(date +%Y%m%d)"
Verify: The output shows a key prefixed sk- exactly once. Copy it immediately to your secrets manager (Step 2). Do not write it to a file, clipboard history, or chat.
Step 2: Store the key in a secrets manager, not environment files
Environment variables are better than hardcoded strings, but .env files checked into git or baked into Docker images are a common leak vector. Use a dedicated secrets manager.
AWS Secrets Manager
aws secretsmanager create-secret \
--name prod/openai/api-key \
--description "OpenAI API key for production inference" \
--secret-string '{"api_key":"sk-..."}'
Retrieve at runtime (example in Python):
import boto3
import json
def get_openai_key() -> str:
client = boto3.client("secretsmanager", region_name="us-east-1")
resp = client.get_secret_value(SecretId="prod/openai/api-key")
return json.loads(resp["SecretString"])["api_key"]
GCP Secret Manager
echo -n "sk-..." | gcloud secrets create openai-api-key --data-file=-
from google.cloud import secretmanager
def get_openai_key() -> str:
client = secretmanager.SecretManagerServiceClient()
name = "projects/my-project/secrets/openai-api-key/versions/latest"
resp = client.access_secret_version(request={"name": name})
return resp.payload.data.decode("utf-8")
HashiCorp Vault (KV v2)
vault kv put secret/openai api_key=sk-...
import hvac
def get_openai_key() -> str:
client = hvac.Client(url="https://vault.internal", token=os.environ["VAULT_TOKEN"])
resp = client.secrets.kv.v2.read_secret_version(path="openai")
return resp["data"]["data"]["api_key"]
Verify: Run your retrieval code in a fresh shell with no OPENAI_API_KEY set. Confirm it returns the key and your application starts successfully.
Step 3: Inject the key at runtime, never at build time
Build-time injection (Docker ARG, ENV in Dockerfile, GitHub Actions env in the job) writes the secret into image layers or workflow logs. Inject only when the process starts.
Kubernetes: use the Secrets Store CSI Driver or External Secrets Operator
# ExternalSecret example
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: openai-key
spec:
refreshInterval: 1h
secretStoreRef:
name: aws-secretsmanager
kind: ClusterSecretStore
target:
name: openai-credentials
creationPolicy: Owner
data:
- secretKey: api_key
remoteRef:
key: prod/openai/api-key
property: api_key
Pod spec:
env:
- name: OPENAI_API_KEY
valueFrom:
secretKeyRef:
name: openai-credentials
key: api_key
Docker Compose (local dev only): use a .env file that is gitignored
# docker-compose.yml
services:
api:
image: my-app:latest
env_file:
- .env.local # not committed
# .env.local (chmod 600)
OPENAI_API_KEY=sk-...
Verify: docker inspect <container> shows no OPENAI_API_KEY in Config.Env. kubectl get secret openai-credentials -o yaml shows base64 data only in the cluster, not in your repo.
Step 4: Restrict the key with IP allowlists and usage limits
OpenAI lets you restrict keys to specific IP ranges and set monthly spend limits. Do both.
- In the OpenAI dashboard, open the key details.
- Add your egress IPs (NAT gateway, VPN, office). Use CIDR notation.
- Set a hard limit (e.g., $500/month) and a soft alert at 80%.
# No CLI for this yet; use the dashboard or the platform API
curl -X POST https://api.openai.com/v1/api_keys/sk-.../restrictions \
-H "Authorization: Bearer $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"allowed_ips": ["203.0.113.0/24"], "monthly_budget_usd": 500}'
Verify: From a non-allowlisted IP, curl -H "Authorization: Bearer sk-..." https://api.openai.com/v1/models returns 401. From an allowlisted IP, it returns 200. Check the usage dashboard after a test request — spend should increment.
Step 5: Rotate keys on a schedule and after any suspected exposure
Rotation limits the blast window of a compromised key. Automate it.
Rotation script (run weekly via cron or scheduled workflow)
#!/usr/bin/env python3
import os
import boto3
import openai
def rotate_key():
admin_key = os.environ["OPENAI_ADMIN_KEY"] # a separate key with key-management scope
client = openai.OpenAI(api_key=admin_key)
# 1. Create new key
new_key = client.api_keys.create(name=f"prod-inference-{datetime.utcnow():%Y%m%d}")
new_secret = new_key.api_key
# 2. Update secrets manager
sm = boto3.client("secretsmanager", region_name="us-east-1")
sm.put_secret_value(
SecretId="prod/openai/api-key",
SecretString=json.dumps({"api_key": new_secret})
)
# 3. Verify new key works
test_client = openai.OpenAI(api_key=new_secret)
test_client.models.list() # raises if invalid
# 4. Revoke old key (get ID from secret metadata or tag)
old_key_id = get_current_key_id() # implement: tag the key at creation
client.api_keys.delete(old_key_id)
print(f"Rotated to {new_key.id[:8]}...")
if __name__ == "__main__":
rotate_key()
Verify: After rotation, the old key returns 401 on /v1/models. The new key returns 200. Secrets manager version increments. Deployment picks up the new version (restart pods or rely on CSI driver refresh).
Step 6: Monitor usage in real time and alert on anomalies
You need visibility into who is using the key, what models, and how many tokens. OpenAI’s dashboard is delayed. Build your own pipeline.
Option A: Proxy through a gateway that logs per-request metadata
If you route traffic through n4n.ai, you get per-token metering, automatic fallback, and structured logs without changing your client code — just point the base URL to the gateway endpoint. The gateway forwards provider cache-control hints and honors your routing directives.
# Client code unchanged except base_url
from openai import OpenAI
client = OpenAI(
api_key=os.environ["OPENAI_API_KEY"],
base_url="https://api.n4n.ai/v1" # gateway endpoint
)
Option B: Middleware in your application
import time
import logging
from openai import OpenAI
from functools import wraps
usage_log = logging.getLogger("openai.usage")
def log_usage(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
start = time.perf_counter()
resp = fn(*args, **kwargs)
latency_ms = (time.perf_counter() - start) * 1000
# Extract usage from response (chat.completions.create returns usage)
usage = getattr(resp, "usage", None)
usage_log.info(
"openai_request",
extra={
"model": kwargs.get("model"),
"prompt_tokens": usage.prompt_tokens if usage else None,
"completion_tokens": usage.completion_tokens if usage else None,
"total_tokens": usage.total_tokens if usage else None,
"latency_ms": latency_ms,
"endpoint": fn.__name__,
}
)
return resp
return wrapper
client = OpenAI(api_key=get_openai_key())
client.chat.completions.create = log_usage(client.chat.completions.create)
Ship logs to your observability stack (Datadog, Splunk, Loki). Alert on:
- Sudden spike in
total_tokensper minute - Requests from unknown user agents or IPs
- 401/429 error rate > 1%
Verify: Trigger a test burst. Confirm your dashboard shows the spike and alert fires within your SLA (e.g., 5 minutes).
Step 7: Enforce client-side validation and request signing
Prevent malformed or oversized requests from reaching OpenAI. This reduces waste and blocks some injection attempts.
from pydantic import BaseModel, Field, validator
from typing import List, Optional
class ChatMessage(BaseModel):
role: str = Field(pattern="^(system|user|assistant|tool)$")
content: str = Field(max_length=100_000) # hard cap
class ChatRequest(BaseModel):
model: str = Field(pattern="^(gpt-4|gpt-4-turbo|gpt-3.5-turbo).*$")
messages: List[ChatMessage] = Field(min_items=1, max_items=100)
temperature: Optional[float] = Field(default=0.7, ge=0, le=2)
max_tokens: Optional[int] = Field(default=None, ge=1, le=4096)
@validator("messages")
def total_chars(cls, v):
total = sum(len(m.content) for m in v)
if total > 200_000:
raise ValueError("Combined message length exceeds 200k chars")
return v
# In your handler
@app.post("/chat")
async def chat(req: ChatRequest):
resp = client.chat.completions.create(**req.dict())
return resp
Verify: Send a request with max_tokens: 100000 — expect 422. Send a 300k-char message — expect 422. Valid request returns 200.
Step 8: Implement a revocation procedure for confirmed leaks
When (not if) a key leaks, you need a runbook that takes < 5 minutes.
Revocation runbook
- Revoke immediately in OpenAI dashboard or via API:
curl -X DELETE https://api.openai.com/v1/api_keys/sk-... \ -H "Authorization: Bearer $ADMIN_KEY" - Rotate using the script from Step 5.
- Audit the last 24h of logs for unauthorized requests (filter by key ID if your gateway logs it).
- Notify security team and, if applicable, customers whose data may have been sent.
- Post-incident: add the leaked key’s fingerprint to a blocklist in your gateway/WAF.
Verify: After revocation, the old key returns 401. New key works. Blocklist returns 403 for any request bearing the leaked key (test with a staged request).
Step 9: Audit key inventory quarterly
Keys accumulate. Service accounts, demo projects, former employees. Schedule a quarterly review.
# List all keys via admin API
curl -H "Authorization: Bearer $ADMIN_KEY" https://api.openai.com/v1/api_keys | jq '.data[] | {id, name, created, last_used}'
Checklist per key:
- Owner identified (team, service)
- IP allowlist current
- Spend limit appropriate
- Last used < 30 days ago (otherwise revoke)
- Stored in secrets manager with correct IAM
Verify: Produce a markdown report committed to your internal docs repo. Action items tracked in your issue tracker.
Step 10: Train developers on the “no key in code” rule
Tooling prevents accidents; culture prevents workarounds.
- Add a pre-commit hook that scans for
sk-patterns:# .pre-commit-config.yaml - repo: https://github.com/gitleaks/gitleaks rev: v8.18.0 hooks: - id: gitleaks - Add a CI job that fails on secret detection:
# .github/workflows/secrets.yml jobs: secret-scan: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: gitleaks/gitleaks-action@v2 - Document the approved path: “Need a key? Ask #platform. We provision via Terraform and inject via CSI driver.”
Verify: Attempt to commit a file containing sk-test123. Pre-commit blocks it. Push anyway — CI fails. The only way to merge is to remove the secret and use the approved flow.
Summary checklist
| Step | Control | Verification |
|---|---|---|
| 1 | Generate in clean env | Key appears once, copied to secrets manager |
| 2 | Store in secrets manager | Retrieval works from fresh shell |
| 3 | Inject at runtime | No key in image layers or build logs |
| 4 | IP allowlist + spend limit | 401 from blocked IP; budget enforced |
| 5 | Automated rotation | Old key revoked, new key works, secret version bumped |
| 6 | Real-time monitoring | Alert fires on test spike within SLA |
| 7 | Request validation | Oversized/malformed requests rejected 422 |
| 8 | Revocation runbook | < 5 min to revoke, rotate, audit |
| 9 | Quarterly audit | Report produced, stale keys revoked |
| 10 | Pre-commit + CI scanning | Secret commit blocked locally and in CI |
Follow these steps and you reduce the likelihood of a leaked key to near zero — and limit the damage if one that damage to minutes, not months.