Skip to content

Diagnostic LLM APIs

Production keys owned by people, not service accounts

The service has been up for two years and it has never once paged anybody about credentials. That is because the credential is Marco's. He minted it in the first week, before the project structure existed, because a personal key works the instant you create it and a service account is a thing you have to think about first. Nothing has gone wrong. Marco is still here, still on the team, still the person who would fix it. The only fact that has changed in two years is that eleven thousand dollars a month now moves through a credential whose lifecycle is attached to one person's employment rather than to the service's.

Read-only key Python and Node.js Tests included
A person holding a credit card in front of a machine
Photo by Nathana Rebouças on Unsplash
The short answer

With an organization admin key: for every project, GET /v1/organization/projects/{project_id}/api_keys?limit=100&owner_project_access=any and keep every key whose owner.type is "user". The owner block gives you owner.user.id, owner.user.email, owner.user.name and owner.user.role.

Then join money to it: GET /v1/organization/costs?start_time={now-30d}&limit=30&group_by=api_key_id, and match on api_key_id. A user-owned key with real amount.value behind it is production traffic running on a personal credential. A user-owned key with nothing behind it is a cleanup task, not an incident, and the script grades them differently.

The third call is the one that turns a list into a verdict: GET /v1/organization/projects/{project_id}/service_accounts. A project with spending user-owned keys and an empty service-account list has never had the alternative at all, which is a different conversation from a project that has service accounts and one straggler.

This is not a concentration check. Two user-owned keys splitting production spend evenly are two findings here, and the share of the bill each holds never enters the verdict.

The problem in plain words

A personal key is the path of least resistance, and it stays the path of least resistance forever. Any project member can mint one and it authenticates immediately. A service account asks you to have an opinion about the project's structure first, at exactly the moment you are trying to get one request to return a 200. So the first credential in every project is somebody's, and the first credential in every project is usually still there years later, because nothing ever forces the question again.

What that binds together is two lifecycles that have nothing to do with each other. The service's lifecycle is about deployments, migrations and decommissioning. The person's lifecycle is about employment, team moves and access reviews. As long as those two never diverge, everything works perfectly, which is why this survives so long: there is no failure state until there is. Then somebody changes teams, or leaves, or has their access reviewed and tidied, and a production credential is inside the blast radius of an HR event.

It also quietly corrupts attribution. Spend on that key rolls up to a user_id in the Usage API, and audit-log entries for actions taken with it name a person. A year of that produces reports that confidently attribute a service's entire consumption to an engineer who has not thought about it since the week they created it.

Personal keymintedit worksimmediatelyService shipson itnothing prompts achangeSpendattributes to awho forgot itexistsTeam move ordeparturean HR event, not adeployEmergencyrotationat 2am, with nooverlap
Every step is somebody making a reasonable choice under time pressure. Nothing here is a mistake on the day it happens.

Why it happens

The finding is the ownership type, and it is deliberately not a share of the bill. The published concentration note owns group_by=api_key_id ranked by share of total: its subject is one key holding most of the money, and its repair is one key per deployable unit. That note is silent when spend is spread evenly, which is precisely when this one has the most to say. The verdict function here is not given a total to divide by, so a share cannot influence it even by accident, and the tests assert that a key holding three per cent of the bill grades identically to one holding ninety-five.

The cost join separates two very different repairs, and nothing else. A user-owned key with money behind it is production standing on a person, and the repair is a migration with a deploy in the middle of it. A user-owned key with no money behind it is somebody's leftover experiment, and the repair is a revocation. Both are owner.type == "user". Grading them the same produces a list that nobody acts on because the urgent rows are buried among the trivial ones.

An empty service-account list is the strongest form of the finding. A project with spending user keys and no service accounts at all has not made a mistake in one place; it has never had the mechanism. That is a project-level finding rather than a per-key one, and it changes what you print: not "migrate this key" but "create the first service account this project has ever had".

This is not the offboarding sweep, and it fires long before that one can. The published note on owners who lost project access reads a flag the provider raises after the binding has already broken. Everything here is still fine: the owner is present, has access, and the flag says active. The whole point is to find the arrangement while it is working, because after it stops working you are doing an emergency rotation instead of a planned one.

Anthropic cannot be asked this question in the same form. Its key object carries created_by, principal and scope, and there is no project service-account object to compare a key against. created_by records who minted the key, which is a different question and one the departed-member note already uses. So this script is OpenAI-only, and says so on startup rather than pretending the Anthropic half is merely unimplemented.

The fix, as a flow

Two lifecycles get bound together on the first afternoon of a project and stay bound for years, because a personal key authenticates immediately and a service account asks you to have an opinion about structure first. There is no failure state until an employment record changes, which is why this survives so long.

owner.type, joined tocost by api_key_idA person owns it, money moves on itmigrate: create, deploy, verify, revokeA person owns it, no spend behind ita revocation, not a migrationSpending user keys, no service accountsthe project has never had the mechanismOwner block missing or unrecognisedunattributable: nobody owns the lifecycleA service account owns itbound to the service, as intended
The share of the bill is not an input. Two personal keys splitting production evenly are two findings here.

How to fix it

Use an admin key with read scopes

/v1/organization/* rejects project keys, so this cannot run on the application's credential. An sk-admin- key provisioned read-only reads projects, keys, service accounts and costs, and can change none of them.

List every project's keys with owner_project_access=any

Walk projects with include_archived=true, then each project's keys with owner_project_access=any. Both parameters default to a narrower answer. Keep every key whose owner.type is "user" and record owner.user.email beside it.

Read the service-account roster for the same project

GET /v1/organization/projects/{project_id}/service_accounts?limit=100 returns id, name, role and created_at. An empty list here, alongside user-owned keys that are spending, is the project-level finding and gets printed once for the project rather than once per key.

Join thirty days of cost by api_key_id

GET /v1/organization/costs?start_time={now-30d}&limit=30&group_by=api_key_id. Match api_key_id to the key ids you collected. Keep the currency with the amount and never sum across currencies: an organization billed in two currencies produces a meaningless total if you add the numbers and drop the units.

Print the migration in order, and run none of it

For each spending personal key: create a service account for the service, mint its key, deploy the new value, confirm traffic moved by re-reading group_by=api_key_id, and only then revoke the old one. The order matters because the service-account key value is returned exactly once at creation, and because revoking before the traffic moves is an outage. The script prints the sequence; you run it.

How to check it worked

Re-run after each migration. The key you moved should show its spend fall to zero while the new service-account key picks it up, and only then should the old key be revoked.

python3 openai_user_owned_key_audit.py --days 30 --min-spend 1.00
# 6 project(s), 38 key(s), 9 owned by a user
# project proj_prod   no service accounts at all, and 2 user-owned key(s) are spending
# personal-key-in-production  proj_prod  api-main    sk-...9c31  marco@example.test  11402.88 USD over 30 day(s)
#   repair: create a service account for this service, mint its key, deploy, confirm the spend moves, then revoke.
# personal-key-in-production  proj_prod  worker-2    sk-...11ab  dana@example.test    9880.10 USD over 30 day(s)
# personal-key-idle           proj_evals scratch     sk-...77de  marco@example.test   no cost rows in 30 day(s)
#   repair: no traffic behind this one, so it is a revocation rather than a migration.
# 3 finding(s), 1 project(s) with no service accounts

The full code

Three GETs and a join. owner_kind is the whole note in six lines, and it treats a missing or unrecognised owner block as unknown rather than folding it into either camp, because an unattributable credential is its own finding. fold_costs keeps currency alongside amount and spend_line refuses to add across currencies. verdict takes one key, that key's own spend and the project's service-account count, and nothing else: there is no organization total in its signature, so a share of the bill cannot reach it. project_note raises the empty-roster case to the project level, and migration_plan prints the ordered cutover with the revocation last.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 97 LLM API fixes, free and open source.
openai_user_owned_key_audit.py
"""Find production keys whose owner is a person rather than a service account.

Read only. Three GETs against the OpenAI Administration API with an admin key:
the project list, each project's keys and service accounts, and the cost report
grouped by api_key_id. Nothing is created, changed or removed, and no key value
is printed.

The finding is the ownership type. This is not a concentration check: the
verdict function is never given an organization total, so the share of the bill
a key holds cannot influence its grade. Two personal keys splitting production
spend evenly are two findings here.

Anthropic is not covered, and not because it was skipped. Its key object has no
owner-type distinction between a person's credential and a service one, and it
has no project service-account object to compare against. created_by records
who minted a key, which is a different question.
"""
import argparse
import datetime as dt
import logging
import os
import sys

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("openai_user_owned_key_audit")

API = "https://api.openai.com/v1"

USER = "user"
SERVICE_ACCOUNT = "service_account"
UNKNOWN = "unknown"

IN_PRODUCTION = "personal-key-in-production"
IDLE = "personal-key-idle"
UNATTRIBUTABLE = "unattributable-owner"
FINE = "service-account-key"
FINDINGS = (IN_PRODUCTION, IDLE, UNATTRIBUTABLE)


def safe_hint(value):
    """Return a key hint that is safe to print. Pure.

    The API returns redacted_value already redacted. Anything that does not
    look redacted is withheld, because an audit script printing a live
    credential into a log is the one mistake here that cannot be undone.
    """
    text = str(value or "").strip()
    if not text:
        return "(no hint)"
    if ("..." not in text and "*" not in text) or len(text) > 40:
        return "(hint withheld)"
    return text


def owner_kind(key):
    """Is this key owned by a person or by a service account? Pure.

    An absent or unrecognised owner block becomes "unknown" and is never folded
    into either camp. A credential nobody can attribute is a finding in its own
    right, and quietly counting it as a service account would hide it.
    """
    owner = (key or {}).get("owner")
    if not isinstance(owner, dict):
        return UNKNOWN
    kind = str(owner.get("type") or "").strip().lower()
    return kind if kind in (USER, SERVICE_ACCOUNT) else UNKNOWN


def owner_label(key):
    """A printable identity for the key's owner. Pure. Never a key value."""
    owner = (key or {}).get("owner")
    if not isinstance(owner, dict):
        return "(no owner block)"
    if owner_kind(key) == USER:
        user = owner.get("user") if isinstance(owner.get("user"), dict) else {}
        return str(user.get("email") or user.get("name") or user.get("id")
                   or "(user, unnamed)")
    if owner_kind(key) == SERVICE_ACCOUNT:
        account = (owner.get("service_account")
                   if isinstance(owner.get("service_account"), dict) else {})
        return str(account.get("name") or account.get("id")
                   or "(service account, unnamed)")
    return "(owner type %r)" % str(owner.get("type"))


def fold_costs(pages):
    """Sum cost by api_key_id, keeping currency. Pure.

    Returns {api_key_id: {currency: amount}}. Currency is kept rather than
    dropped because an organization billed in more than one currency produces a
    meaningless number the moment the units are discarded.
    """
    out = {}
    for page in pages or []:
        for bucket in (page or {}).get("data") or []:
            for result in (bucket or {}).get("results") or []:
                key_id = (result or {}).get("api_key_id")
                amount = (result or {}).get("amount")
                if not key_id or not isinstance(amount, dict):
                    continue
                try:
                    value = float(amount.get("value") or 0)
                except (TypeError, ValueError):
                    continue
                currency = str(amount.get("currency") or "USD").upper()
                out.setdefault(str(key_id), {})
                out[str(key_id)][currency] = \
                    out[str(key_id)].get(currency, 0.0) + value
    return out


def spend_of(costs, key_id):
    """The largest single-currency amount recorded for one key. Pure.

    Used only to compare against a threshold. Taking the maximum rather than a
    sum keeps the comparison honest in a multi-currency organization without
    inventing an exchange rate.
    """
    by_currency = (costs or {}).get(str(key_id or ""), {})
    return max(by_currency.values()) if by_currency else 0.0


def spend_line(costs, key_id, days):
    """A printable spend summary for one key. Pure. Never adds currencies."""
    by_currency = (costs or {}).get(str(key_id or ""), {})
    if not by_currency:
        return "no cost rows in %d day(s)" % days
    parts = ["%.2f %s" % (value, currency)
             for currency, value in sorted(by_currency.items())]
    return "%s over %d day(s)" % (" + ".join(parts), days)


def verdict(key, key_spend, service_account_count, min_spend=1.0):
    """Classify one key by who owns it. Pure. Returns (state, detail).

    Deliberately not given an organization total. The share of the bill this
    key holds is not an input, cannot be an input, and is the subject of a
    different note; a personal key carrying three per cent of production spend
    grades exactly as a personal key carrying ninety-five.
    """
    kind = owner_kind(key)
    if kind == SERVICE_ACCOUNT:
        return (FINE, "owned by a service account")
    if kind == UNKNOWN:
        return (UNATTRIBUTABLE,
                "the owner block is missing or its type is unrecognised, so "
                "nobody can say whose lifecycle this credential is attached to")
    if float(key_spend or 0) >= float(min_spend):
        return (IN_PRODUCTION,
                "a person owns a credential carrying production spend%s"
                % (", in a project with no service accounts at all"
                   if not service_account_count else ""))
    return (IDLE,
            "owned by a person and carrying no measurable spend, so this is a "
            "revocation rather than a migration")


def project_note(project_name, user_owned_spending, service_account_count):
    """The project-level finding, printed once per project. Pure.

    A project with spending personal keys and an empty service-account roster
    has not made a mistake in one place. It has never had the mechanism, and
    the repair is different: create the first one rather than migrate to the
    existing ones.
    """
    if user_owned_spending and not service_account_count:
        return ("project %s: no service accounts at all, and %d user-owned "
                "key(s) are spending" % (project_name, user_owned_spending))
    return None


def migration_plan(project_id, key_id, key_name):
    """The ordered cutover, printed and never performed. Pure.

    Revocation is last because the service-account key value is returned once
    at creation and because removing the old key before traffic moves is an
    outage rather than a rotation.
    """
    return [
        "create a service account for the service: an admin POST to "
        "/v1/organization/projects/%s/service_accounts with a name that "
        "matches the deployable unit, not the person." % project_id,
        "mint its key under /v1/organization/projects/%s/service_accounts/"
        "{service_account_id}/api_keys. The value is returned exactly once, "
        "so capture it into the secret store in the same step." % project_id,
        "deploy the new value, then re-read the cost report grouped by "
        "api_key_id and confirm the spend has moved off %s (%s)."
        % (key_name, key_id),
        "only then revoke the old key with a DELETE on "
        "/v1/organization/projects/%s/api_keys/%s." % (project_id, key_id),
    ]


def get(session, path, params):
    r = session.get(API + path, params=params, timeout=60)
    if r.status_code in (401, 403):
        raise SystemExit("%d from OpenAI: /v1/organization/* needs an admin "
                         "key (sk-admin-), not a project key" % r.status_code)
    r.raise_for_status()
    return r.json()


def paged(session, path, params):
    """Walk an administration listing on has_more / last_id."""
    params = dict(params)
    while True:
        page = get(session, path, params)
        yield page
        if not page.get("has_more") or not page.get("last_id"):
            return
        params["after"] = page["last_id"]


def collect(session, path, params):
    rows = []
    for page in paged(session, path, params):
        rows.extend(page.get("data") or [])
    return rows


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--days", type=int, default=30,
                    help="days of cost to join by api_key_id (default 30)")
    ap.add_argument("--min-spend", type=float, default=1.0,
                    help="spend above which a personal key is production")
    args = ap.parse_args()

    admin = os.environ.get("OPENAI_ADMIN_KEY")
    if not admin:
        log.error("set OPENAI_ADMIN_KEY to an admin key (sk-admin-) with read "
                  "scopes; a project key cannot read /v1/organization/*")
        return 2

    s = requests.Session()
    s.headers.update({"Authorization": "Bearer " + admin})

    start = int((dt.datetime.now(dt.timezone.utc)
                 - dt.timedelta(days=args.days)).timestamp())
    costs = fold_costs(paged(s, "/organization/costs",
                             {"start_time": start, "limit": min(args.days, 180),
                              "group_by": "api_key_id"}))

    projects = collect(s, "/organization/projects",
                       {"limit": 100, "include_archived": "true"})

    total_keys = 0
    user_owned = 0
    findings = 0
    empty_rosters = 0

    for project in projects:
        pid = project.get("id")
        if not pid:
            continue
        name = project.get("name") or pid
        keys = collect(s, "/organization/projects/%s/api_keys" % pid,
                       {"limit": 100, "owner_project_access": "any"})
        accounts = collect(s, "/organization/projects/%s/service_accounts" % pid,
                           {"limit": 100})
        total_keys += len(keys)

        graded = []
        for key in keys:
            key_spend = spend_of(costs, key.get("id"))
            state, detail = verdict(key, key_spend, len(accounts), args.min_spend)
            graded.append((key, state, detail, key_spend))
            if owner_kind(key) == USER:
                user_owned += 1

        spending = sum(1 for _, state, _, _ in graded if state == IN_PRODUCTION)
        note = project_note(name, spending, len(accounts))
        if note:
            empty_rosters += 1
            log.warning(note)

        for key, state, detail, _ in sorted(
                graded, key=lambda row: -spend_of(costs, row[0].get("id"))):
            if state not in FINDINGS:
                continue
            findings += 1
            log.warning("%-27s %-12s %-12s %s  %-24s %s", state, name,
                        key.get("name") or "(unnamed)",
                        safe_hint(key.get("redacted_value")), owner_label(key),
                        spend_line(costs, key.get("id"), args.days))
            log.warning("  detail: %s", detail)
            if state == IN_PRODUCTION:
                for step in migration_plan(pid, key.get("id"),
                                           key.get("name") or "(unnamed)"):
                    log.warning("  repair: %s", step)
            elif state == IDLE:
                log.warning("  repair: no traffic behind this one, so it is a "
                            "revocation rather than a migration.")

    log.info("%d project(s), %d key(s), %d owned by a user", len(projects),
             total_keys, user_owned)
    log.info("%d finding(s), %d project(s) with no service accounts",
             findings, empty_rosters)
    log.info("share of the bill is not part of any verdict above: that is a "
             "different note and a different repair")
    return 1 if findings else 0


if __name__ == "__main__":
    sys.exit(main())
openai-user-owned-key-audit.mjs
/**
 * Find production keys whose owner is a person rather than a service account.
 *
 * Read only. Three GETs against the OpenAI Administration API with an admin
 * key. Nothing is created, changed or removed, and no key value is printed.
 *
 * The finding is the ownership type. The verdict function is never given an
 * organization total, so the share of the bill a key holds cannot influence
 * its grade: two personal keys splitting production spend evenly are two
 * findings here.
 *
 * Anthropic is not covered. Its key object has no owner-type distinction
 * between a person's credential and a service one, and no project
 * service-account object to compare against.
 */
const API = 'https://api.openai.com/v1';

export const USER = 'user';
export const SERVICE_ACCOUNT = 'service_account';
export const UNKNOWN = 'unknown';

export const IN_PRODUCTION = 'personal-key-in-production';
export const IDLE = 'personal-key-idle';
export const UNATTRIBUTABLE = 'unattributable-owner';
export const FINE = 'service-account-key';
const FINDINGS = new Set([IN_PRODUCTION, IDLE, UNATTRIBUTABLE]);

/** A key hint that is safe to print. Pure. Anything unredacted is withheld. */
export function safeHint(value) {
  const text = String(value ?? '').trim();
  if (!text) return '(no hint)';
  if ((!text.includes('...') && !text.includes('*')) || text.length > 40) {
    return '(hint withheld)';
  }
  return text;
}

/** Is this key owned by a person or a service account? Pure. Unknown stays unknown. */
export function ownerKind(key) {
  const owner = (key ?? {}).owner;
  if (!owner || typeof owner !== 'object') return UNKNOWN;
  const kind = String(owner.type ?? '').trim().toLowerCase();
  return (kind === USER || kind === SERVICE_ACCOUNT) ? kind : UNKNOWN;
}

/** A printable identity for the key's owner. Pure. Never a key value. */
export function ownerLabel(key) {
  const owner = (key ?? {}).owner;
  if (!owner || typeof owner !== 'object') return '(no owner block)';
  const kind = ownerKind(key);
  if (kind === USER) {
    const user = (owner.user && typeof owner.user === 'object') ? owner.user : {};
    return String(user.email ?? user.name ?? user.id ?? '(user, unnamed)');
  }
  if (kind === SERVICE_ACCOUNT) {
    const account = (owner.service_account && typeof owner.service_account === 'object')
      ? owner.service_account : {};
    return String(account.name ?? account.id ?? '(service account, unnamed)');
  }
  return `(owner type ${JSON.stringify(String(owner.type))})`;
}

/** Sum cost by api_key_id, keeping currency. Pure. */
export function foldCosts(pages) {
  const out = {};
  for (const page of pages ?? []) {
    for (const bucket of page?.data ?? []) {
      for (const result of bucket?.results ?? []) {
        const keyId = result?.api_key_id;
        const amount = result?.amount;
        if (!keyId || !amount || typeof amount !== 'object') continue;
        const value = Number(amount.value ?? 0);
        if (!Number.isFinite(value)) continue;
        const currency = String(amount.currency ?? 'USD').toUpperCase();
        out[String(keyId)] = out[String(keyId)] ?? {};
        out[String(keyId)][currency] = (out[String(keyId)][currency] ?? 0) + value;
      }
    }
  }
  return out;
}

/** The largest single-currency amount recorded for one key. Pure. */
export function spendOf(costs, keyId) {
  const byCurrency = (costs ?? {})[String(keyId ?? '')] ?? {};
  const values = Object.values(byCurrency);
  return values.length ? Math.max(...values) : 0;
}

/** A printable spend summary for one key. Pure. Never adds currencies. */
export function spendLine(costs, keyId, days) {
  const byCurrency = (costs ?? {})[String(keyId ?? '')] ?? {};
  const entries = Object.entries(byCurrency).sort(([a], [b]) => a.localeCompare(b));
  if (!entries.length) return `no cost rows in ${days} day(s)`;
  const parts = entries.map(([currency, value]) => `${value.toFixed(2)} ${currency}`);
  return `${parts.join(' + ')} over ${days} day(s)`;
}

/** Classify one key by who owns it. Pure. No organization total is an input. */
export function verdict(key, keySpend, serviceAccountCount, minSpend = 1.0) {
  const kind = ownerKind(key);
  if (kind === SERVICE_ACCOUNT) return [FINE, 'owned by a service account'];
  if (kind === UNKNOWN) {
    return [UNATTRIBUTABLE,
      'the owner block is missing or its type is unrecognised, so nobody can ' +
      'say whose lifecycle this credential is attached to'];
  }
  if (Number(keySpend ?? 0) >= Number(minSpend)) {
    return [IN_PRODUCTION,
      'a person owns a credential carrying production spend' +
      (serviceAccountCount ? '' : ', in a project with no service accounts at all')];
  }
  return [IDLE,
    'owned by a person and carrying no measurable spend, so this is a ' +
    'revocation rather than a migration'];
}

/** The project-level finding, printed once per project. Pure. */
export function projectNote(projectName, userOwnedSpending, serviceAccountCount) {
  if (userOwnedSpending && !serviceAccountCount) {
    return `project ${projectName}: no service accounts at all, and ` +
           `${userOwnedSpending} user-owned key(s) are spending`;
  }
  return null;
}

/** The ordered cutover, printed and never performed. Pure. */
export function migrationPlan(projectId, keyId, keyName) {
  return [
    'create a service account for the service: an admin POST to ' +
    `/v1/organization/projects/${projectId}/service_accounts with a name ` +
    'that matches the deployable unit, not the person.',
    `mint its key under /v1/organization/projects/${projectId}/service_accounts/` +
    '{service_account_id}/api_keys. The value is returned exactly once, so ' +
    'capture it into the secret store in the same step.',
    'deploy the new value, then re-read the cost report grouped by ' +
    `api_key_id and confirm the spend has moved off ${keyName} (${keyId}).`,
    'only then revoke the old key with a DELETE on ' +
    `/v1/organization/projects/${projectId}/api_keys/${keyId}.`,
  ];
}

async function getJson(key, path, params) {
  const url = new URL(API + path);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v));
  const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } });
  if (res.status === 401 || res.status === 403) {
    throw new Error(`${res.status} from OpenAI: /v1/organization/* needs an ` +
                    'admin key (sk-admin-), not a project key');
  }
  if (!res.ok) throw new Error(`${res.status} from ${path}`);
  return res.json();
}

async function collect(key, path, params) {
  const rows = [];
  const pages = [];
  let after = null;
  for (;;) {
    const page = await getJson(key, path, after ? { ...params, after } : params);
    pages.push(page);
    rows.push(...(page.data ?? []));
    if (!page.has_more || !page.last_id) return { rows, pages };
    after = page.last_id;
  }
}

async function main() {
  const admin = process.env.OPENAI_ADMIN_KEY;
  if (!admin) {
    console.error('set OPENAI_ADMIN_KEY to an admin key (sk-admin-) with read ' +
                  'scopes; a project key cannot read /v1/organization/*');
    process.exitCode = 2;
    return;
  }
  const days = Number(process.env.DAYS ?? 30);
  const minSpend = Number(process.env.MIN_SPEND ?? 1.0);
  const start = Math.floor(Date.now() / 1000) - days * 86400;

  const costPages = await collect(admin, '/organization/costs', {
    start_time: start, limit: Math.min(days, 180), group_by: 'api_key_id' });
  const costs = foldCosts(costPages.pages);

  const projects = (await collect(admin, '/organization/projects',
                                  { limit: 100, include_archived: 'true' })).rows;

  let totalKeys = 0;
  let userOwned = 0;
  let findings = 0;
  let emptyRosters = 0;

  for (const project of projects) {
    if (!project.id) continue;
    const name = project.name ?? project.id;
    const keys = (await collect(admin, `/organization/projects/${project.id}/api_keys`,
                                { limit: 100, owner_project_access: 'any' })).rows;
    const accounts = (await collect(
      admin, `/organization/projects/${project.id}/service_accounts`, { limit: 100 })).rows;
    totalKeys += keys.length;

    const graded = keys.map((key) => {
      const keySpend = spendOf(costs, key.id);
      const [state, detail] = verdict(key, keySpend, accounts.length, minSpend);
      if (ownerKind(key) === USER) userOwned += 1;
      return { key, state, detail, keySpend };
    });

    const spending = graded.filter((row) => row.state === IN_PRODUCTION).length;
    const note = projectNote(name, spending, accounts.length);
    if (note) { emptyRosters += 1; console.warn(note); }

    for (const row of graded.sort((a, b) => b.keySpend - a.keySpend)) {
      if (!FINDINGS.has(row.state)) continue;
      findings += 1;
      console.warn(`${row.state.padEnd(27)} ${String(name).padEnd(12)} ` +
                   `${String(row.key.name ?? '(unnamed)').padEnd(12)} ` +
                   `${safeHint(row.key.redacted_value)}  ` +
                   `${ownerLabel(row.key).padEnd(24)} ` +
                   `${spendLine(costs, row.key.id, days)}`);
      console.warn(`  detail: ${row.detail}`);
      if (row.state === IN_PRODUCTION) {
        for (const step of migrationPlan(project.id, row.key.id,
                                         row.key.name ?? '(unnamed)')) {
          console.warn(`  repair: ${step}`);
        }
      } else if (row.state === IDLE) {
        console.warn('  repair: no traffic behind this one, so it is a ' +
                     'revocation rather than a migration.');
      }
    }
  }

  console.log(`${projects.length} project(s), ${totalKeys} key(s), ` +
              `${userOwned} owned by a user`);
  console.log(`${findings} finding(s), ${emptyRosters} project(s) with no service accounts`);
  console.log('share of the bill is not part of any verdict above: that is a ' +
              'different note and a different repair');
  process.exitCode = findings ? 1 : 0;
}

if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The first test is the distinctness rule written as an assertion: the same personal key graded against three per cent of the bill and against ninety-five per cent has to produce the identical verdict, and two keys splitting production spend evenly have to produce two findings rather than none. That is the line between this note and the published concentration note, and it is checked rather than promised. The rest: an owner block that is missing or carries an unexpected type, which must land in unattributable-owner and never in either camp; the empty service-account roster, which is a project-level line and not a per-key one; a two-currency organization, whose amounts must be reported side by side and never added; and the migration plan, whose revocation step must come last.

test_openai_user_owned_key_audit.py
from openai_user_owned_key_audit import (fold_costs, migration_plan,
                                          owner_kind, owner_label,
                                          project_note, safe_hint, spend_line,
                                          spend_of, verdict)


def user_key(key_id, name, email):
    return {"id": key_id, "name": name, "redacted_value": "sk-...9c31",
            "owner": {"type": "user", "user": {"id": "user_1", "email": email}},
            "owner_project_access": "active"}


def service_key(key_id, name):
    return {"id": key_id, "name": name, "redacted_value": "sk-...aa02",
            "owner": {"type": "service_account",
                      "service_account": {"id": "svc_1", "name": "ingest"}}}


def cost_page(rows):
    return {"data": [{"results": [
        {"api_key_id": key_id, "amount": {"value": value, "currency": currency}}
        for key_id, value, currency in rows]}], "has_more": False}


def test_the_share_of_the_bill_is_not_part_of_the_verdict():
    # The line between this note and the published concentration note, as an
    # assertion. One key holding 3% of production grades exactly as one
    # holding 95%, and an even split is two findings rather than none.
    key = user_key("key_1", "api-main", "marco@example.test")
    tiny = verdict(key, 340.00, service_account_count=2)
    huge = verdict(key, 11402.88, service_account_count=2)
    assert tiny[0] == huge[0] == "personal-key-in-production"
    assert tiny[1] == huge[1]

    even = fold_costs([cost_page([("key_1", 5000.0, "USD"),
                                  ("key_2", 5000.0, "USD")])])
    a = verdict(user_key("key_1", "api-main", "marco@example.test"),
                spend_of(even, "key_1"), 1)
    b = verdict(user_key("key_2", "worker-2", "dana@example.test"),
                spend_of(even, "key_2"), 1)
    assert [a[0], b[0]] == ["personal-key-in-production"] * 2


def test_a_personal_key_with_no_traffic_is_a_different_repair():
    key = user_key("key_9", "scratch", "marco@example.test")
    state, detail = verdict(key, 0.0, service_account_count=2)
    assert state == "personal-key-idle"
    assert "revocation rather than a migration" in detail
    assert verdict(service_key("key_s", "ingest"), 90000.0, 2)[0] == \
        "service-account-key"


def test_an_unrecognised_owner_is_never_folded_into_either_camp():
    assert owner_kind({"owner": {"type": "user"}}) == "user"
    assert owner_kind({"owner": {"type": "SERVICE_ACCOUNT"}}) == "service_account"
    assert owner_kind({"owner": {"type": "robot"}}) == "unknown"
    assert owner_kind({"owner": None}) == "unknown"
    assert owner_kind({}) == "unknown"
    assert owner_kind(None) == "unknown"
    state, detail = verdict({"owner": {"type": "robot"}}, 4000.0, 3)
    assert state == "unattributable-owner"
    assert "whose lifecycle" in detail
    assert owner_label({"owner": {"type": "robot"}}) == "(owner type 'robot')"
    assert owner_label(user_key("k", "n", "d@example.test")) == "d@example.test"
    assert owner_label(service_key("k", "n")) == "ingest"
    assert owner_label({}) == "(no owner block)"


def test_an_empty_service_account_roster_is_a_project_level_finding():
    assert project_note("proj_prod", 2, 0) == \
        "project proj_prod: no service accounts at all, and 2 user-owned key(s) are spending"
    assert project_note("proj_prod", 2, 3) is None
    assert project_note("proj_evals", 0, 0) is None
    state, detail = verdict(user_key("key_1", "api-main", "m@example.test"),
                            9000.0, service_account_count=0)
    assert state == "personal-key-in-production"
    assert "no service accounts at all" in detail


def test_two_currencies_are_reported_side_by_side_and_never_added():
    costs = fold_costs([cost_page([("key_1", 400.0, "USD"),
                                   ("key_1", 300.0, "USD"),
                                   ("key_1", 120.0, "EUR")])])
    assert costs["key_1"] == {"USD": 700.0, "EUR": 120.0}
    line = spend_line(costs, "key_1", 30)
    assert line == "120.00 EUR + 700.00 USD over 30 day(s)"
    assert "820" not in line
    # The threshold comparison uses the largest single currency, never a total.
    assert spend_of(costs, "key_1") == 700.0
    assert spend_of(costs, "key_absent") == 0.0
    assert spend_line(costs, "key_absent", 30) == "no cost rows in 30 day(s)"


def test_cost_rows_that_cannot_be_read_are_skipped_rather_than_guessed():
    costs = fold_costs([{"data": [{"results": [
        {"api_key_id": None, "amount": {"value": 5.0, "currency": "USD"}},
        {"api_key_id": "key_1", "amount": None},
        {"api_key_id": "key_1", "amount": {"value": "many", "currency": "USD"}},
        {"api_key_id": "key_1", "amount": {"value": 12.5}},
    ]}]}])
    assert costs == {"key_1": {"USD": 12.5}}
    assert fold_costs([]) == {}
    assert fold_costs(None) == {}


def test_the_migration_puts_the_revocation_last():
    steps = migration_plan("proj_prod", "key_1", "api-main")
    assert len(steps) == 4
    assert "service_accounts" in steps[0]
    assert "returned exactly once" in steps[1]
    assert "confirm the spend has moved off" in steps[2]
    assert steps[3].startswith("only then revoke")
    assert safe_hint("sk-...9c31") == "sk-...9c31"
    assert safe_hint("sk-fake-whole-value-here") == "(hint withheld)"
    assert safe_hint(None) == "(no hint)"
openai-user-owned-key-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { foldCosts, migrationPlan, ownerKind, ownerLabel, projectNote,
         safeHint, spendLine, spendOf, verdict }
  from './openai-user-owned-key-audit.mjs';

const userKey = (id, name, email) => ({
  id, name, redacted_value: 'sk-...9c31',
  owner: { type: 'user', user: { id: 'user_1', email } },
  owner_project_access: 'active',
});

const serviceKey = (id, name) => ({
  id, name, redacted_value: 'sk-...aa02',
  owner: { type: 'service_account', service_account: { id: 'svc_1', name: 'ingest' } },
});

const costPage = (rows) => ({
  data: [{ results: rows.map(([api_key_id, value, currency]) =>
    ({ api_key_id, amount: { value, currency } })) }],
  has_more: false,
});

test('the share of the bill is not part of the verdict', () => {
  const key = userKey('key_1', 'api-main', 'marco@example.test');
  const tiny = verdict(key, 340.0, 2);
  const huge = verdict(key, 11402.88, 2);
  assert.equal(tiny[0], 'personal-key-in-production');
  assert.deepEqual(tiny, huge);

  const even = foldCosts([costPage([['key_1', 5000.0, 'USD'],
                                    ['key_2', 5000.0, 'USD']])]);
  assert.equal(verdict(userKey('key_1', 'api-main', 'm@example.test'),
                       spendOf(even, 'key_1'), 1)[0], 'personal-key-in-production');
  assert.equal(verdict(userKey('key_2', 'worker-2', 'd@example.test'),
                       spendOf(even, 'key_2'), 1)[0], 'personal-key-in-production');
});

test('a personal key with no traffic is a different repair', () => {
  const [state, detail] = verdict(userKey('key_9', 'scratch', 'm@example.test'), 0.0, 2);
  assert.equal(state, 'personal-key-idle');
  assert.match(detail, /revocation rather than a migration/);
  assert.equal(verdict(serviceKey('key_s', 'ingest'), 90000.0, 2)[0],
               'service-account-key');
});

test('an unrecognised owner is never folded into either camp', () => {
  assert.equal(ownerKind({ owner: { type: 'user' } }), 'user');
  assert.equal(ownerKind({ owner: { type: 'SERVICE_ACCOUNT' } }), 'service_account');
  assert.equal(ownerKind({ owner: { type: 'robot' } }), 'unknown');
  assert.equal(ownerKind({ owner: null }), 'unknown');
  assert.equal(ownerKind({}), 'unknown');
  assert.equal(ownerKind(null), 'unknown');
  const [state, detail] = verdict({ owner: { type: 'robot' } }, 4000.0, 3);
  assert.equal(state, 'unattributable-owner');
  assert.match(detail, /whose lifecycle/);
  assert.equal(ownerLabel(userKey('k', 'n', 'd@example.test')), 'd@example.test');
  assert.equal(ownerLabel(serviceKey('k', 'n')), 'ingest');
  assert.equal(ownerLabel({}), '(no owner block)');
});

test('an empty service account roster is a project level finding', () => {
  assert.equal(projectNote('proj_prod', 2, 0),
    'project proj_prod: no service accounts at all, and 2 user-owned key(s) are spending');
  assert.equal(projectNote('proj_prod', 2, 3), null);
  assert.equal(projectNote('proj_evals', 0, 0), null);
  const [state, detail] = verdict(userKey('key_1', 'api-main', 'm@example.test'), 9000.0, 0);
  assert.equal(state, 'personal-key-in-production');
  assert.match(detail, /no service accounts at all/);
});

test('two currencies are reported side by side and never added', () => {
  const costs = foldCosts([costPage([['key_1', 400.0, 'USD'],
                                     ['key_1', 300.0, 'USD'],
                                     ['key_1', 120.0, 'EUR']])]);
  assert.deepEqual(costs.key_1, { USD: 700.0, EUR: 120.0 });
  const line = spendLine(costs, 'key_1', 30);
  assert.equal(line, '120.00 EUR + 700.00 USD over 30 day(s)');
  assert.ok(!line.includes('820'));
  assert.equal(spendOf(costs, 'key_1'), 700.0);
  assert.equal(spendOf(costs, 'key_absent'), 0);
  assert.equal(spendLine(costs, 'key_absent', 30), 'no cost rows in 30 day(s)');
});

test('cost rows that cannot be read are skipped rather than guessed', () => {
  const costs = foldCosts([{ data: [{ results: [
    { api_key_id: null, amount: { value: 5.0, currency: 'USD' } },
    { api_key_id: 'key_1', amount: null },
    { api_key_id: 'key_1', amount: { value: 'many', currency: 'USD' } },
    { api_key_id: 'key_1', amount: { value: 12.5 } },
  ] }] }]);
  assert.deepEqual(costs, { key_1: { USD: 12.5 } });
  assert.deepEqual(foldCosts([]), {});
  assert.deepEqual(foldCosts(null), {});
});

test('the migration puts the revocation last', () => {
  const steps = migrationPlan('proj_prod', 'key_1', 'api-main');
  assert.equal(steps.length, 4);
  assert.match(steps[0], /service_accounts/);
  assert.match(steps[1], /returned exactly once/);
  assert.match(steps[2], /confirm the spend has moved off/);
  assert.ok(steps[3].startsWith('only then revoke'));
  assert.equal(safeHint('sk-...9c31'), 'sk-...9c31');
  assert.equal(safeHint('sk-fake-whole-value-here'), '(hint withheld)');
  assert.equal(safeHint(null), '(no hint)');
});

FAQ

Is this not the same as finding the key that dominates the bill?

No, and the difference is worth being precise about because both start from group_by=api_key_id. The concentration note asks whether one key holds most of the money, and its repair is to split traffic across several keys so that spend can be attributed and capped. This note asks who owns the credential, and its repair is to move the traffic from a person to a service account. An organization that split its traffic across six evenly-loaded keys has fixed the first problem completely; if all six belong to engineers, it has not touched this one. The verdict function here is not even given an organization total, so a share cannot influence it.

Nothing has gone wrong in two years. Why is this a finding at all?

Because the failure mode is not gradual. As long as the person stays in place with their access unchanged, a personal key behaves exactly like a service credential, which is why these survive for years. The problem is that the credential's lifetime is bound to an employment record: a team move, a departure, or a routine access review can end it, and the first symptom is production failing to authenticate. Finding it while everything is fine means you do a planned rotation with a deploy in the middle. Finding it afterwards means you do the same work at 2am.

What does the service-account list add that the key list does not?

It separates a straggler from a project that has never had the mechanism. A project with eight service accounts and one personal key left over has a migration to finish. A project with spending personal keys and an empty service-account roster has no alternative in place at all, so the repair is not to move this key but to create the first service account this project has ever had, and it applies to every key in there rather than to one. The script prints that once per project rather than repeating it per key.

Why not run the same check against Anthropic?

Because the field does not exist. Anthropic's API key object carries id, created_at, created_by, expires_at, name, partial_key_hint, principal, scope, status and type, and there is no project service-account object anywhere in the Admin API to compare a key against. created_by tells you which member minted the key, which is a genuinely useful field and a different question, and it is the one the published note on keys whose owner has left already uses. Writing an Anthropic half here would mean presenting created_by as if it were owner.type, which is not what it means.

Can the script do the migration if I give it a write-scoped key?

It has no write path to enable. There is no flag, no confirmation prompt and no code that issues anything but a GET, which is a deliberate property of every script in this section rather than a default that can be turned off. The migration is printed in order for a reason too: the service-account key value is returned exactly once at creation, and revoking the old key before the new one is deployed and verified is an outage rather than a rotation. Both of those steps want a human watching them.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.