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

TypeFormatPurposeWhere stored
Admin keysk-... (Admin Keys section)Org management ONLY: costs, projects, audit logs, admin key CRUD. Cannot be used for inference.secrets/monitoring.env.sopsOPENAI_ADMIN_KEY + GH Secret
Project keysk-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"]) not apiKey=.


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)

OperationMethodPath
ListGET/organization/admin_api_keys
CreatePOST/organization/admin_api_keys
RetrieveGET/organization/admin_api_keys/{key_id}
DeleteDELETE/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)

OperationMethodPath
ListGET/organization/projects/{project_id}/api_keys
RetrieveGET/organization/projects/{project_id}/api_keys/{key_id}
DeleteDELETE/organization/projects/{project_id}/api_keys/{key_id}
CreateNot 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)
        raise

3. Key locations and SOPS mapping

Secret nameSOPS fileGH SecretNotes
OPENAI_ADMIN_KEYsecrets/monitoring.env.sopsOPENAI_ADMIN_KEYBOM in GH Secrets — always strip
WAP_OPENAI_KEY_MINIsecrets/monitoring.env.sopsMust be sk-proj-... inference key, NOT admin key
OPENAI_MONITORING_TOKENauto-rotated by credential-rotation.ymlOPENAI_MONITORING_TOKENProject 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.sopsOPENAI_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):

  1. 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_keys and 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).
  2. POST /organization/projects/proj_YnpqoGhWyCbwpeTUcahfY5sK/service_accounts {"name": "et-op-production-<date>"} with OPENAI_ADMIN_KEYsvc.api_key.value is the new key (shown once).
  3. Verify before distributing: GET /v1/models with 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.
  4. 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 to main mid-task, secrets-sync.yml fired 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 a tmp/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.
  5. Distribute to Vercel: et-op project prj_ziLl911FOYLAeukQujL4NjxR4eWy, team team_RuXLTWx5DXK6n0HUKoOUTGY3. GET /v9/projects/{id}/env?teamId=... to find the existing OPENAI_API_KEY env id, then PATCH /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-list GET /v9/projects/{id}/env afterwards if you need the id again later in the same session; don’t assume a previously-captured id stays valid.
  6. Update secrets/et-operational-platform.env.sops via scripts/sops-set.ps1 (never hand-roll the decrypt/encrypt pipe on Windows — piping sops --encrypt output through Out-File with any encoding other than a direct WriteAllText no-BOM write corrupts the SOPS timestamp metadata; sops-set.ps1 already does this safely). Commit and push the branch promptly — until this lands on main, an unrelated secrets-sync.yml run (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).
  7. Do not deliberately trigger secrets-sync.yml yourself 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.)
  8. 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 is DELETE /organization/projects/{id}/service_accounts/{id} (list via GET .../service_accounts to 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.
  9. Log the rotation in docs/secrets-rotation-log.md with 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.

  1. Navigate https://platform.openai.com/api-keys
  2. Create new secret key → name: wap-openai-mini → project: Default → permissions: All
  3. Copy value from dialog → mask immediately
  4. 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 push on file path instead
  • GitHub App needs workflows permission (in App settings + installation acceptance)
  • SOPS on bms4: check command -v sops before 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

KeyFrequencyMethod
OPENAI_ADMIN_KEY180dAutonomous via Admin API (POST /organization/admin_api_keys)
WAP_OPENAI_KEY_MINI180dAutonomous via service account API (POST /organization/projects/{id}/service_accounts)
OPENAI_MONITORING_TOKEN90dAuto 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 = ''