OpenAI Key Management & Rotation
Owner: p24-infra · Updated: 2026-08-04
API reference: https://developers.openai.com/api/reference/resources/admin/subresources/organization/
1. Key types — critical distinction
| Type | Format | Purpose | Where stored |
|---|---|---|---|
| Admin key | sk-... (Admin Keys section) | Org management ONLY: costs, projects, audit logs, admin key CRUD. Cannot be used for inference. | secrets/monitoring.env.sops → OPENAI_ADMIN_KEY + GH Secret |
| Project key | sk-proj-... | Model inference for apps (WAP, et-op, monitoring). Cannot call Admin API. | secrets/monitoring.env.sops per consumer (e.g. WAP_OPENAI_KEY_MINI) |
CRITICAL: These two key types are completely separate. An admin key used in inference → auth error. A project key used in Admin API → auth error. Admin SDK init:
OpenAI(adminAPIKey=os.environ["OPENAI_ADMIN_KEY"])notapiKey=.
2. Admin API endpoints — what Claude can do autonomously
Base URL: https://api.openai.com
Auth: Authorization: Bearer <OPENAI_ADMIN_KEY> (with BOM strip — see §8)
Admin API keys (/organization/admin_api_keys)
| Operation | Method | Path |
|---|---|---|
| List | GET | /organization/admin_api_keys |
| Create ✅ | POST | /organization/admin_api_keys |
| Retrieve | GET | /organization/admin_api_keys/{key_id} |
| Delete | DELETE | /organization/admin_api_keys/{key_id} |
Create response returns the key value (only time it’s visible) → mask immediately with ::add-mask::.
Project API keys (/organization/projects/{id}/api_keys)
| Operation | Method | Path |
|---|---|---|
| List | GET | /organization/projects/{project_id}/api_keys |
| Retrieve | GET | /organization/projects/{project_id}/api_keys/{key_id} |
| Delete | DELETE | /organization/projects/{project_id}/api_keys/{key_id} |
| Create ❌ | — | Not available via API |
Project keys cannot be created via Admin API. Only via dashboard or Playwright (see §4).
Other useful Admin API endpoints
# List projects
GET /organization/projects?limit=20
# Cost data (used by openai-monitor.py)
GET /organization/costs?start_time=...&end_time=...&bucket_width=1d
# Invite user
POST /organization/invites {"email": "...", "role": "reader"}Python helper (use in all scripts/workflows)
import os, json, urllib.request, urllib.error
def get_admin_key():
# BOM strip mandatory — key stored in GH Secrets with BOM prefix
return os.environ["OPENAI_ADMIN_KEY"].encode().decode("utf-8-sig").strip()
def openai_admin(method, path, body=None):
url = f"https://api.openai.com{path}"
data = json.dumps(body).encode() if body else None
req = urllib.request.Request(url, data=data, method=method,
headers={"Authorization": f"Bearer {get_admin_key()}",
"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req) as r:
return json.loads(r.read())
except urllib.error.HTTPError as e:
print(f"OpenAI {method} {path} => {e.code}: {e.read().decode()}", file=sys.stderr)
raise3. Key locations and SOPS mapping
| Secret name | SOPS file | GH Secret | Notes |
|---|---|---|---|
OPENAI_ADMIN_KEY | secrets/monitoring.env.sops | OPENAI_ADMIN_KEY ✓ | BOM in GH Secrets — always strip |
WAP_OPENAI_KEY_MINI | secrets/monitoring.env.sops | — | Must be sk-proj-... inference key, NOT admin key |
OPENAI_MONITORING_TOKEN | auto-rotated by credential-rotation.yml | OPENAI_MONITORING_TOKEN | Project key for cost monitoring |
4. Creating a project inference key (WAP_OPENAI_KEY_MINI etc.)
Project inference keys (sk-proj-...) CAN be created autonomously via service accounts.
The POST /organization/projects/{project_id}/api_keys endpoint does not exist (405), but
POST /organization/projects/{project_id}/service_accounts returns a response that includes the key.
Path A — Service account via Admin API (fully autonomous ✅)
import os, json, sys, urllib.request, urllib.error
from datetime import datetime, timezone
def openai_admin(method, path, body=None):
key = os.environ["OPENAI_ADMIN_KEY"].encode().decode("utf-8-sig").strip()
url = f"https://api.openai.com{path}"
data = json.dumps(body).encode() if body else None
req = urllib.request.Request(url, data=data, method=method,
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"})
with urllib.request.urlopen(req) as r:
return json.loads(r.read())
# 1. Find project
projects = openai_admin("GET", "/organization/projects?limit=20")["data"]
project = next((p for p in projects if "default" in p["name"].lower()), projects[0])
project_id = project["id"]
# 2. Create service account → response includes the API key
date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
svc = openai_admin("POST", f"/organization/projects/{project_id}/service_accounts",
{"name": f"wap-openai-mini-{date}"})
# svc["api_key"]["value"] = the sk-proj-... key (only returned once)
new_key = svc["api_key"]["value"]
print(f"::add-mask::{new_key}")
print(f"Service account created: {svc['id']} — key id: {svc['api_key']['id']}")Response shape:
{
"id": "...",
"name": "wap-openai-mini-2026-07-01",
"object": "organization.project.service_account",
"role": "member",
"api_key": {
"id": "...",
"value": "sk-proj-...",
"name": "wap-openai-mini-2026-07-01",
"created_at": 1751299200,
"object": "organization.project.service_account.api_key"
}
}Path A applied to OPENAI_API_KEY (et-op) — proven 2026-08-04
This exact Path A pattern also works for secrets/et-operational-platform.env.sops →
OPENAI_API_KEY (et-op project key), not just WAP_OPENAI_KEY_MINI. Prior guidance
(docs/secrets-rotation-access-matrix.md, issue #2889 and predecessors 2824)
wrongly treated this key as permanently human-only — that conclusion was correct about
POST /organization/projects/{id}/api_keys (no create method exists there, confirmed twice)
but wrong about the operation being impossible: service_accounts was never tried against
this specific key until 2026-08-04.
Steps (identical to Path A above, with et-op-specific distribution):
GET /organization/projects?limit=20→ identify the correct project. et-op’s project is the Default project (proj_YnpqoGhWyCbwpeTUcahfY5sK) — do not assume; if project names ever change, re-verify by listing each candidate project’s/api_keysand matching the last 4 characters of the currently-configured key (never match on full value or a long prefix — that’s already close to exposing it).POST /organization/projects/proj_YnpqoGhWyCbwpeTUcahfY5sK/service_accounts{"name": "et-op-production-<date>"}withOPENAI_ADMIN_KEY→svc.api_key.valueis the new key (shown once).- Verify before distributing:
GET /v1/modelswith the new key → expect 200. Also check the old key the same way first — if it’s already 401, production may already be broken; treat the rotation as more urgent, not less. - Work from an isolated
git worktree, never the shared session working directory — this is not optional for this specific rotation. On 2026-08-04 a first attempt working directly in the shared repo directory lost the minted key entirely: a concurrent process pushed tomainmid-task,secrets-sync.ymlfired and overwrote the just-deployed Vercel value back to the stale key before SOPS had been committed, and a separate branch/checkout collision in the same shared directory reset uncommitted local changes. Since OpenAI only shows a key value once, at creation, there was no way to recover it — the minted service account had to be discarded and the whole rotation redone from atmp/wt-<issue>worktree. Do steps 2, 5, and 6 (create, deploy to Vercel, commit SOPS) as close together as practical to shrink the window in which an unrelated sync could observe a partially-applied state. - Distribute to Vercel: et-op project
prj_ziLl911FOYLAeukQujL4NjxR4eWy, teamteam_RuXLTWx5DXK6n0HUKoOUTGY3.GET /v9/projects/{id}/env?teamId=...to find the existingOPENAI_API_KEYenv id, thenPATCH /v9/projects/{id}/env/{envId}?teamId=...{"value": "<new>", "target": ["production","preview","development"]}. Note: Vercel appears to assign the env var a new id on update rather than truly mutating the existing row in place — re-listGET /v9/projects/{id}/envafterwards if you need the id again later in the same session; don’t assume a previously-captured id stays valid. - Update
secrets/et-operational-platform.env.sopsviascripts/sops-set.ps1(never hand-roll the decrypt/encrypt pipe on Windows — pipingsops --encryptoutput throughOut-Filewith any encoding other than a directWriteAllTextno-BOM write corrupts the SOPS timestamp metadata;sops-set.ps1already does this safely). Commit and push the branch promptly — until this lands onmain, an unrelatedsecrets-sync.ymlrun (triggered by anyone else’s push) will re-sync Vercel from the old SOPS value and silently clobber the new key you just deployed (see finding in step 4). - Do not deliberately trigger
secrets-sync.ymlyourself after this — Vercel and SOPS are already directly in sync from steps 5–6; a self-triggered sync would be a no-op at best. (An unrelated sync triggered by someone else’s push is the actual risk — see step 4/6.) - Identify and delete the superseded service account, not just the old api key resource
—
DELETE /organization/projects/{id}/api_keys/{key_id}returns 400 for service-account- issued keys; the correct call isDELETE /organization/projects/{id}/service_accounts/{id}(list viaGET .../service_accountsto get the account id, matched by name). Only delete once you’ve positively identified the target — an unmatched/ambiguous old key is safer left alone than guessed at; note it for a follow-up instead. - Log the rotation in
docs/secrets-rotation-log.mdwith the service account id, api key id, and both live-verification results (before/after).
Known gap from the 2026-06-30 attempt: a service account named et-operational-platform-sa
was found still live and unused when this was re-attempted 2026-08-04 — created the same day
issue #2273 was closed as failed, but its key value was never captured or distributed anywhere
(absent from SOPS, Vercel, GH Secrets, and the rotation log). If you find an orphaned service
account like this during any future OpenAI rotation, it cannot be recovered (OpenAI only shows
the key value once, at creation) — just delete it as cleanup and note it in the rotation log;
do not assume it’s safe to treat as “already done.”
Path B — Playwright (autonomous when MCP active)
Check availability: ToolSearch("playwright browser") — if tools found, proceed.
- Navigate
https://platform.openai.com/api-keys - Create new secret key → name:
wap-openai-mini→ project: Default → permissions: All - Copy value from dialog → mask immediately
- Store to SOPS (§5)
Path C — User creates, Claude stores (fallback)
User goes to platform.openai.com/api-keys → Create → copies value → pastes in terminal ONLY:
$env:NEW_OPENAI_KEY = "sk-proj-..."Claude reads $env:NEW_OPENAI_KEY, stores to SOPS via §5, clears variable. Never appears in chat.
5. Rotating OPENAI_ADMIN_KEY (fully autonomous via Admin API)
# 1. Create new admin key
new = openai_admin("POST", "/organization/admin_api_keys", {"name": f"p24-infra-{date}"})
new_key = new["value"] # only time visible — mask immediately
new_key_id = new["id"]
print(f"::add-mask::{new_key}")
# 2. Store to SOPS (§6) and GH Secret
# gh secret set OPENAI_ADMIN_KEY --body "$NEW_KEY" --repo radieu/p24-infra
# 3. List and delete old keys
old_keys = openai_admin("GET", "/organization/admin_api_keys")["data"]
for k in old_keys:
if k["id"] != new_key_id:
openai_admin("DELETE", f"/organization/admin_api_keys/{k['id']}")
print(f"Deleted old key: {k['id']} ({k.get('name','')})")GH Actions workflow template: credential-rotation.yml (extend for OPENAI_ADMIN_KEY).
6. Adding / updating a key in SOPS (local Windows)
$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
# Decrypt to temp file (MUST be named *.env.sops in secrets/ to match .sops.yaml)
sops --decrypt --input-type dotenv --output-type dotenv `
secrets\monitoring.env.sops > secrets\monitoring-edit.env.sops
# Edit secrets\monitoring-edit.env.sops in VSCode — add/replace key line
# NEVER paste value in chat
# Re-encrypt
sops --encrypt --input-type dotenv --output-type dotenv `
secrets\monitoring-edit.env.sops > secrets\monitoring.env.sops
# Canary
sops --decrypt --input-type dotenv --output-type dotenv `
secrets\monitoring.env.sops | Out-Null
if ($LASTEXITCODE -ne 0) { throw "SOPS corrupt — do NOT commit" }
Remove-Item secrets\monitoring-edit.env.sops
git add secrets\monitoring.env.sops
git commit -m "secrets: add/rotate OPENAI_* key"7. Adding a key from GH Secrets to SOPS (GH Actions on bms4)
Use when key is in GH Secrets but not locally accessible (write-only API).
Proven patterns (2026-07-01):
- Runner:
[self-hosted, bms4]— ubuntu-latest cannot reach api.openai.com - BOM strip:
sed 's/^\xef\xbb\xbf//' | tr -d '\r\n ' - workflow_dispatch trigger requires workflow on default branch → use
pushon file path instead - GitHub App needs
workflowspermission (in App settings + installation acceptance) - SOPS on bms4: check
command -v sopsbefore installing
8. BOM bug — OPENAI_ADMIN_KEY in GH Secrets
Symptom: UnicodeEncodeError: 'latin-1' codec can't encode character ''
Cause: Key stored in GH Secrets with UTF-8 BOM prefix (\xef\xbb\xbf).
Fix — Python:
key = os.environ["OPENAI_ADMIN_KEY"].encode().decode("utf-8-sig").strip()Fix — bash:
CLEAN_KEY=$(printf '%s' "$OPENAI_ADMIN_KEY" | sed 's/^\xef\xbb\xbf//' | tr -d '\r\n ')Applied in: scripts/openai-monitor.py:27.
9. Rotation schedule
| Key | Frequency | Method |
|---|---|---|
OPENAI_ADMIN_KEY | 180d | Autonomous via Admin API (POST /organization/admin_api_keys) |
WAP_OPENAI_KEY_MINI | 180d | Autonomous via service account API (POST /organization/projects/{id}/service_accounts) |
OPENAI_MONITORING_TOKEN | 90d | Auto via credential-rotation.yml |
Audit Log — Log to infra_operations
After this operation completes, log it to the infra_operations audit table.
Python (Linux server — bms-4, vps-i1, vps-h1, or similar):
import sys
sys.path.insert(0, '/opt/p24-infra')
from scripts.lib.log_op import log_op
log_op(
actor="claude", # "radieu" for manual human ops, "claude" for agent
op_type="credential_rotation",
resource="OPENAI_API_KEY",
result="success", # "success" | "failed" | "skipped"
detail="Rotation — OpenAI API key replaced and SOPS updated",
env="bms-4",
gh_issue=2730,
)PowerShell (Windows dev machine):
$env:SUPABASE_URL = (Get-Content "C:\code_2026\p24-infra\.env.local" | Select-String "^SUPABASE_URL=").ToString().Split("=",2)[1].Trim()
$env:SUPABASE_SERVICE_KEY = (Get-Content "C:\code_2026\p24-infra\.env.local" | Select-String "^SUPABASE_SERVICE_KEY=").ToString().Split("=",2)[1].Trim()
python -c "
import os, sys
sys.path.insert(0, 'C:/code_2026/p24-infra')
from scripts.lib.log_op import log_op
log_op('claude', 'credential_rotation', 'OPENAI_API_KEY', 'success', 'Rotation — OpenAI API key replaced and SOPS updated', 'bms-4')
"
$env:SUPABASE_URL = ''; $env:SUPABASE_SERVICE_KEY = ''