Skip to content

Diagnostic LLM APIs

an archived project still holds live API keys

The prototype was shut down in the spring. The project was archived, which felt like closing it: it vanished from the console's project switcher and from every list anyone looks at. Nothing inside it was touched. Two keys are still enabled, one of them authenticated a request last Tuesday, and the quarterly key audit has never seen either of them, because the audit iterates projects and archived projects are not in the default listing.

Read-only key Python and Node.js Tests included
Text
Photo by Tamanna Rumee on Unsplash
The short answer

With an organization admin key, call GET /v1/organization/projects?limit=100&include_archived=true. That parameter is the whole trick: archived projects are excluded by default, so any audit that omits it is auditing a subset of your organization by construction.

Filter to status == "archived" (equivalently archived_at != null) and then, for each one, GET /v1/organization/projects/{project_id}/api_keys?limit=100&owner_project_access=any. Any key returned is live inside a project everyone considers closed. Escalate any whose last_used_at is later than the project's archived_at: the project is still serving traffic.

An admin key provisioned read-only is enough, and an admin key is required — a project key gets a 401 from every /v1/organization/* path.

The problem in plain words

Archiving reads as a closing action. It is a visibility action. POST /v1/organization/projects/{project_id}/archive sets archived_at, flips status to "archived", and removes the project from default listings and the console switcher. It does not enumerate the credentials inside, does not disable them, and does not warn you that any exist. Projects cannot be deleted at all, so archiving is the only closing gesture available, which is exactly why people reach for it and assume it does more than it does.

What is left behind is the least-monitored credential in the organization. It is live, it is attached to a project nobody opens, its spend rolls into a cost report line for a project name nobody recognises, and it is structurally absent from the sweep that was supposed to catch it. Every other key in your org is at least in a list somewhere. These are not.

The audit blind spot is the part worth internalising. A key audit that walks projects without include_archived=true does not report an error, does not report a smaller number, and does not hint that anything is missing. It returns a clean result over a partial universe, which is the most convincing kind of wrong answer.

Prototype windsdownteam moves onProjectarchivedthe only closingactionKeys leftenablednothing cascadesDropped fromlistingsdefault excludesarchivedAudit neversees themand reports clean
Archiving changes two fields on the project. It does not enumerate, disable or delete anything inside it.

Why it happens

Archive is a filter, not a revocation. The operation changes two fields on the project object. Nothing cascades to the keys, the service accounts, the files or the vector stores inside it.

The exclusion is the default, not the exception. include_archived defaults to false, so you have to know the parameter exists before you can ask the question. Nobody writes a script to include a category of thing they do not know is being hidden.

Projects cannot be deleted, so archiving absorbs every kind of ending. Finished prototype, cancelled customer, migrated workload, wrong name at creation: all of them end in the same state, which means the archived list is where the organization's history accumulates, keys and all.

An archived project can still bill. Nothing stops a key inside it from calling the API. GET /v1/organization/costs?start_time={now-30d}&group_by=project_id returning a non-zero amount for an archived project_id is a project that was closed on paper and is spending money in fact.

The keys inside outlive their owners too. Archiving a project is often part of a team winding down, so the keys in there are disproportionately likely to be owned by people who have since left — the two findings compound, and the combined case is invisible from both directions.

The fix, as a flow

The script prints whether its own listing covered archived projects before it prints any finding, because the failure this note describes is an audit that returns a clean result over a partial universe.

Projects with include_archivedkeys read per archived projectArchived, no keysgenuinely closedKeys never usedrevoke, no riskLast used pre archivedead weight, removeUsed after archivingclosed on paper only
The comparison is last_used_at against archived_at, which is the only way to tell dead weight from an integration nobody knows is running.

How to fix it

List projects twice and compare the counts

Call GET /v1/organization/projects?limit=100 and then again with include_archived=true. The difference between the two counts is the number of projects your existing audits have never looked at. Doing it this way once is worth more than being told the parameter exists.

Select the archived ones properly

status == "archived" and archived_at != null should agree. If they disagree, trust status and report the object, because a project in an unexpected shape is a finding rather than a row to skip.

Enumerate the keys inside each

GET /v1/organization/projects/{project_id}/api_keys?limit=100&owner_project_access=any. Use any here rather than accepting the default: you want the full key surface of a project nobody is watching, not a filtered view of it.

Compare last_used_at against archived_at

Both are unix timestamps. A key used after the archive date means the project is still doing work, which is a live integration nobody has an owner for. A key that has not been used since before the archive is dead weight you can remove immediately. A key with a null last_used_at has never been used at all.

Corroborate with spend, then fix the audit itself

GET /v1/organization/costs?start_time={now-30d}&group_by=project_id tells you whether an archived project is still costing money. Then revoke with DELETE /v1/organization/projects/{project_id}/api_keys/{api_key_id} per key — and change the standing audit job to pass include_archived=true, because that line is the durable half of the repair.

How to check it worked

Re-run the script. Every archived project should report zero live keys, and the coverage line should confirm the listing included them.

python3 openai_archived_project_keys.py
# listing covers archived projects: yes
# 9 project(s), 3 archived, 0 live key(s) inside them

The full code

One paginated GET for the projects and one per archived project for its keys, with an organization admin key because /v1/organization/* rejects project keys; read-only admin scopes are enough and are what you should give it. Two pure functions carry the note: one asks whether a listing call would have included archived projects at all, which is the mistake this note exists for, and one classifies an archived project against the keys found inside it.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 24 LLM API fixes, free and open source.
openai_archived_project_keys.py
"""Report live API keys sitting inside archived OpenAI projects.

Read only. GET requests and nothing else, with an ORGANIZATION ADMIN key
(sk-admin-...) because /v1/organization/* rejects project keys; read-only admin
scopes are enough. The repair is printed, never performed.

Archiving a project hides it from the default listing without revoking anything
inside it, so the parameter below is the whole point of the script.
"""
import argparse
import logging
import os
import sys
import time

import requests

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

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

TRUTHY = ("true", "1", "yes", "on")


def covers_archived(params):
    """True when a projects listing will actually include archived projects.

    Pure. include_archived defaults to false, so a key audit that never passes
    it is auditing a subset of the organization and reporting a clean result
    over it. Accepts the bool and the query-string spelling, because the value
    reaches the API as a string either way.
    """
    value = params.get("include_archived")
    if isinstance(value, bool):
        return value
    if value is None:
        return False
    return str(value).strip().lower() in TRUTHY


def verdict(project, keys, now):
    """Classify one project against the keys found inside it.

    Pure, so the comparison between a key's last_used_at and the project's
    archived_at is testable without an admin credential. All three timestamps
    are unix seconds; last_used_at is null on a key that has never been used.

    Returns (state, detail).
    """
    status = str(project.get("status") or "").strip().lower()
    archived_at = project.get("archived_at")
    if status != "archived" and archived_at is None:
        return ("active", "not archived; outside the scope of this check")

    keys = list(keys or [])
    if not keys:
        return ("clean", "archived, and holds no API keys")

    used_after = [k for k in keys
                  if k.get("last_used_at") and archived_at
                  and int(k["last_used_at"]) > int(archived_at)]
    if used_after:
        newest = max(int(k["last_used_at"]) for k in used_after)
        return ("still-serving",
                "%d of %d live key(s) authenticated a request after the project "
                "was archived, the most recent %d day(s) ago. This project is "
                "closed on paper and running in fact."
                % (len(used_after), len(keys), (int(now) - newest) // DAY))

    ever_used = [k for k in keys if k.get("last_used_at")]
    if ever_used:
        newest = max(int(k["last_used_at"]) for k in ever_used)
        return ("live-keys",
                "%d live key(s) inside an archived project, last used %d day(s) "
                "ago. Nothing has needed them since the archive."
                % (len(keys), (int(now) - newest) // DAY))
    return ("dormant-keys",
            "%d live key(s) inside an archived project, none of which has ever "
            "authenticated a request" % len(keys))


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


def paged(session, path, **params):
    params.setdefault("limit", 100)
    while True:
        page = get(session, path, **params)
        data = page.get("data") or []
        for item in data:
            yield item
        if not page.get("has_more") or not data:
            return
        params["after"] = page.get("last_id") or data[-1].get("id")


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--show-active", action="store_true",
                    help="also print the projects that are not archived")
    args = ap.parse_args()

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

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

    listing = {"limit": 100, "include_archived": "true"}
    # Stated out loud, because the silent version of this mistake is what the
    # note is about: a sweep that omits the parameter reports a clean subset.
    log.info("listing covers archived projects: %s",
             "yes" if covers_archived(listing) else "NO, this audit is partial")

    projects = list(paged(s, "/organization/projects", **listing))
    archived = 0
    exposed = 0
    for project in projects:
        keys = []
        if str(project.get("status") or "").lower() == "archived" \
                or project.get("archived_at") is not None:
            archived += 1
            keys = list(paged(s, "/organization/projects/%s/api_keys" % project["id"],
                              owner_project_access="any"))
        state, detail = verdict(project, keys, now)
        line = "%-13s %s  %s" % (state, project.get("name") or project["id"], detail)
        if state in ("active", "clean"):
            if state == "clean" or args.show_active:
                log.info(line)
            continue
        exposed += len(keys)
        log.warning(line)
        for key in keys:
            log.warning("  repair: DELETE %s/organization/projects/%s/api_keys/%s  (%s)",
                        API, project["id"], key.get("id"),
                        key.get("redacted_value") or key.get("name") or "unnamed")
        log.warning("  and check the spend: GET %s/organization/costs"
                    "?start_time=<now-30d>&group_by=project_id", API)

    log.info("%d project(s), %d archived, %d live key(s) inside them",
             len(projects), archived, exposed)
    return 1 if exposed else 0


if __name__ == "__main__":
    sys.exit(main())
openai-archived-project-keys.mjs
/**
 * Report live API keys sitting inside archived OpenAI projects.
 *
 * Read only. GET requests and nothing else, with an ORGANIZATION ADMIN key
 * (sk-admin-...) because /v1/organization/* rejects project keys; read-only
 * admin scopes are enough. The repair is printed, never performed.
 */
const API = 'https://api.openai.com/v1';
const DAY = 86400;
const TRUTHY = ['true', '1', 'yes', 'on'];

/**
 * True when a projects listing will actually include archived projects. Pure.
 * include_archived defaults to false, so an audit that never passes it reports
 * a clean result over a subset of the organization.
 */
export function coversArchived(params = {}) {
  const value = params.include_archived;
  if (typeof value === 'boolean') return value;
  if (value === undefined || value === null) return false;
  return TRUTHY.includes(String(value).trim().toLowerCase());
}

/**
 * Classify one project against the keys found inside it. Pure. All timestamps
 * are unix seconds; last_used_at is null on a key that has never been used.
 */
export function verdict(project, keys, now) {
  const status = String(project.status ?? '').trim().toLowerCase();
  const archivedAt = project.archived_at;
  if (status !== 'archived' && (archivedAt === undefined || archivedAt === null)) {
    return ['active', 'not archived; outside the scope of this check'];
  }

  const all = [...(keys ?? [])];
  if (all.length === 0) return ['clean', 'archived, and holds no API keys'];

  const usedAfter = all.filter((k) => k.last_used_at && archivedAt &&
                                      Number(k.last_used_at) > Number(archivedAt));
  if (usedAfter.length > 0) {
    const newest = Math.max(...usedAfter.map((k) => Number(k.last_used_at)));
    const days = Math.floor((Number(now) - newest) / DAY);
    return ['still-serving',
      `${usedAfter.length} of ${all.length} live key(s) authenticated a request ` +
      `after the project was archived, the most recent ${days} day(s) ago. This ` +
      'project is closed on paper and running in fact.'];
  }

  const everUsed = all.filter((k) => k.last_used_at);
  if (everUsed.length > 0) {
    const newest = Math.max(...everUsed.map((k) => Number(k.last_used_at)));
    const days = Math.floor((Number(now) - newest) / DAY);
    return ['live-keys',
      `${all.length} live key(s) inside an archived project, last used ${days} ` +
      'day(s) ago. Nothing has needed them since the archive.'];
  }
  return ['dormant-keys',
    `${all.length} live key(s) inside an archived project, none of which has ` +
    'ever authenticated a request'];
}

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

async function* paged(adminKey, path, params = {}) {
  const q = { limit: 100, ...params };
  for (;;) {
    const page = await get(adminKey, path, q);
    const data = page.data ?? [];
    for (const item of data) yield item;
    if (!page.has_more || data.length === 0) return;
    q.after = page.last_id ?? data[data.length - 1].id;
  }
}

async function main() {
  const adminKey = process.env.OPENAI_ADMIN_KEY;
  if (!adminKey) {
    console.error('set OPENAI_ADMIN_KEY to an organization admin key (sk-admin-...); ' +
                  'a project key cannot read /v1/organization/*');
    process.exitCode = 2;
    return;
  }
  const now = Math.floor(Date.now() / 1000);

  const listing = { limit: 100, include_archived: 'true' };
  console.log(`listing covers archived projects: ${
    coversArchived(listing) ? 'yes' : 'NO, this audit is partial'}`);

  const projects = [];
  for await (const p of paged(adminKey, '/organization/projects', listing)) projects.push(p);

  let archived = 0;
  let exposed = 0;
  for (const project of projects) {
    let keys = [];
    const isArchived = String(project.status ?? '').toLowerCase() === 'archived' ||
                       (project.archived_at !== undefined && project.archived_at !== null);
    if (isArchived) {
      archived += 1;
      for await (const k of paged(adminKey,
                                  `/organization/projects/${project.id}/api_keys`,
                                  { owner_project_access: 'any' })) keys.push(k);
    }
    const [state, detail] = verdict(project, keys, now);
    const line = `${state.padEnd(13)} ${project.name ?? project.id}  ${detail}`;
    if (state === 'active' || state === 'clean') {
      if (state === 'clean') console.log(line);
      continue;
    }
    exposed += keys.length;
    console.warn(line);
    for (const key of keys) {
      console.warn(`  repair: DELETE ${API}/organization/projects/${project.id}` +
                   `/api_keys/${key.id}  (${key.redacted_value ?? key.name ?? 'unnamed'})`);
    }
    console.warn(`  and check the spend: GET ${API}/organization/costs` +
                 '?start_time=<now-30d>&group_by=project_id');
  }

  console.log(`${projects.length} project(s), ${archived} archived, ${exposed} ` +
              'live key(s) inside them');
  process.exitCode = exposed ? 1 : 0;
}

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

Add a test

Two things are pinned here. The first is the coverage check, because include_archived arrives as the string "false" often enough that a truthiness test on it passes and the audit silently narrows to the projects it was always going to see. The second is the difference between a key last used before the archive and one used after it: the same count of live keys, and completely different urgency.

test_openai_archived_project_keys.py
from openai_archived_project_keys import covers_archived, verdict

NOW = 1_756_000_000
ARCHIVED_AT = NOW - 120 * 86400


def project(**over):
    p = {"id": "proj_x", "name": "prototype", "status": "archived",
         "archived_at": ARCHIVED_AT}
    p.update(over)
    return p


def key(last_used_at=None, **over):
    k = {"id": "key_1", "redacted_value": "sk-proj-...9f2c",
         "last_used_at": last_used_at}
    k.update(over)
    return k


def test_a_listing_without_the_parameter_does_not_cover_archived():
    assert covers_archived({"limit": 100}) is False


def test_the_string_false_is_not_truthy_here():
    # The quiet version of this bug: a non-empty string read as "yes".
    assert covers_archived({"include_archived": "false"}) is False
    assert covers_archived({"include_archived": False}) is False


def test_the_parameter_is_recognised_in_the_spellings_that_reach_the_api():
    assert covers_archived({"include_archived": "true"}) is True
    assert covers_archived({"include_archived": "TRUE"}) is True
    assert covers_archived({"include_archived": True}) is True
    assert covers_archived({"include_archived": "1"}) is True


def test_an_active_project_is_out_of_scope():
    state, _ = verdict(project(status="active", archived_at=None), [key(NOW)], NOW)
    assert state == "active"


def test_an_archived_project_with_no_keys_is_clean():
    assert verdict(project(), [], NOW)[0] == "clean"


def test_a_key_used_after_the_archive_is_the_urgent_case():
    state, detail = verdict(project(), [key(ARCHIVED_AT + 10 * 86400)], NOW)
    assert state == "still-serving"
    assert "closed on paper" in detail


def test_a_key_last_used_before_the_archive_is_dead_weight():
    state, detail = verdict(project(), [key(ARCHIVED_AT - 5 * 86400)], NOW)
    assert state == "live-keys"
    assert "since the archive" in detail


def test_a_never_used_key_is_still_reported():
    state, detail = verdict(project(), [key(None)], NOW)
    assert state == "dormant-keys"
    assert "has ever authenticated" in detail


def test_status_archived_without_a_timestamp_is_still_archived():
    # Nothing to compare last_used_at against, so it cannot be still-serving,
    # but it must not fall through to "active" either.
    state, _ = verdict(project(archived_at=None), [key(NOW - 86400)], NOW)
    assert state == "live-keys"
openai-archived-project-keys.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { coversArchived, verdict } from './openai-archived-project-keys.mjs';

const NOW = 1_756_000_000;
const ARCHIVED_AT = NOW - 120 * 86400;

const project = (over = {}) => ({
  id: 'proj_x', name: 'prototype', status: 'archived', archived_at: ARCHIVED_AT, ...over,
});
const key = (lastUsedAt = null, over = {}) => ({
  id: 'key_1', redacted_value: 'sk-proj-...9f2c', last_used_at: lastUsedAt, ...over,
});

test('a listing without the parameter does not cover archived', () => {
  assert.equal(coversArchived({ limit: 100 }), false);
  assert.equal(coversArchived(), false);
});

test('the string false is not truthy here', () => {
  assert.equal(coversArchived({ include_archived: 'false' }), false);
  assert.equal(coversArchived({ include_archived: false }), false);
});

test('the parameter is recognised in the spellings that reach the API', () => {
  assert.equal(coversArchived({ include_archived: 'true' }), true);
  assert.equal(coversArchived({ include_archived: 'TRUE' }), true);
  assert.equal(coversArchived({ include_archived: true }), true);
  assert.equal(coversArchived({ include_archived: '1' }), true);
});

test('an active project is out of scope', () => {
  assert.equal(
    verdict(project({ status: 'active', archived_at: null }), [key(NOW)], NOW)[0],
    'active');
});

test('an archived project with no keys is clean', () => {
  assert.equal(verdict(project(), [], NOW)[0], 'clean');
});

test('a key used after the archive is the urgent case', () => {
  const [state, detail] = verdict(project(), [key(ARCHIVED_AT + 10 * 86400)], NOW);
  assert.equal(state, 'still-serving');
  assert.match(detail, /closed on paper/);
});

test('a key last used before the archive is dead weight', () => {
  const [state, detail] = verdict(project(), [key(ARCHIVED_AT - 5 * 86400)], NOW);
  assert.equal(state, 'live-keys');
  assert.match(detail, /since the archive/);
});

test('a never used key is still reported', () => {
  const [state, detail] = verdict(project(), [key(null)], NOW);
  assert.equal(state, 'dormant-keys');
  assert.match(detail, /has ever authenticated/);
});

test('status archived without a timestamp is still archived', () => {
  assert.equal(verdict(project({ archived_at: null }), [key(NOW - 86400)], NOW)[0],
               'live-keys');
});

FAQ

Does archiving a project revoke its API keys?

No. Archiving sets archived_at and flips status to archived, which removes the project from default listings and from the console's project switcher. The keys inside remain enabled and continue to authenticate requests and bill to the organization until somebody deletes them individually.

Why can't I just delete the project instead?

Projects cannot be deleted, only archived. That is why archiving carries so much weight in practice: it is the only closing gesture the API offers, so every kind of ending lands in the same state, and the archived list becomes where the organization's history accumulates along with its credentials.

What does include_archived actually change?

It changes which projects the listing returns. Without it the response omits archived projects entirely, and there is no field or count telling you that anything was omitted. That is why an audit missing the parameter reports a clean result rather than an incomplete one.

How do I know whether an archived project is still doing work?

Compare each key's last_used_at against the project's archived_at. A key used after the archive date means something is still calling the API through that project. Confirm it in money with GET /v1/organization/costs?start_time=<now-30d>&group_by=project_id, which shows a non-zero amount for an archived project_id that is still spending.

Does this need an admin key?

Yes. Both the projects listing and the project API keys listing live under /v1/organization/, which rejects project keys with a 401. Use an organization admin key with read scopes only. Admin describes what it can see; this script only reads.

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.