# How to audit the scopes of the tokens your integrations use

> Inventory every long-lived token your services and CI jobs hold, map each to a week of calls, and re-issue it at the smallest scope behind a flag.

Source: https://voxgig.com/howto/audit-the-scopes-of-integration-tokens

- Audience: platform-team
- Level: intermediate
- Verified: 2026-09-24
- Published: 2026-09-24

## Short answer

List every long-lived token from the secret store and the CI settings. Join the list to a week of API logs, so each token shows the scopes it used and the ones it never touched. Re-issue the narrow token beside the broad one and pick between them with a flag, so a denial is a flag flip from rollback. Alert on `insufficient_scope` rather than deleting, and watch a full job cycle before you revoke.

---
## You will need

Node 22 or later, and the list of services and CI jobs that call third-party APIs. Verified
2026-09-24 against Node 22.22.2. The sample stands in for GitHub and AWS with a route table and
a resource server. The server refuses a call the way
[RFC 6750 section 3.1](https://www.rfc-editor.org/rfc/rfc6750.html#section-3.1) describes: a 403,
the error `insufficient_scope`, and the scope that was missing. The route table is a transcript
of GitHub's lists of the
[permission each endpoint needs](https://docs.github.com/en/rest/authentication/permissions-required-for-fine-grained-personal-access-tokens)
and the [classic scopes](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps),
and an AWS action names its own permission.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [AWS IAM Access Analyzer unused access findings](https://docs.aws.amazon.com/IAM/latest/UserGuide/access-analyzer-findings.html) | AWS roles and access keys, where you want the evidence gathered for you over a tracking period you set | A paid analyzer per account, findings for AWS credentials alone, and a tracking period that starts when you create it | The tokens are for GitHub, Stripe, or anything outside AWS |
| [GitHub fine-grained personal access tokens](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) | Scopes per repository and per permission, read or write, with an owner who can require approval | A token per owner, endpoints the fine-grained list leaves out, and an expiry an organization can force | The job needs an endpoint the list does not cover, or spans organizations |
| [GitHub personal access tokens (classic)](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps) | Every endpoint, every organization the user is in, and the `X-OAuth-Scopes` header to read the grant back | Scopes such as `repo` cover every repository the user can see, so the smallest grant is still large | You can name the repositories the job touches |
| [OIDC federation from CI to a cloud role](https://docs.github.com/en/actions/concepts/security/openid-connect) | CI jobs that reach a cloud provider, where a per-job token replaces a stored key | A trust policy and a role per job, and nothing for the APIs that take no federated token | The caller is a long-running service, or the API takes only its own tokens |

Two of the rows remove the long-lived token and two make it smaller. OIDC federation replaces a
stored key with a token that lasts one job, which is the strongest form. It covers only a caller
that can present a federated token to a provider that accepts one. Access Analyzer gathers the
same evidence for you, at a price, and for AWS credentials alone. The two
GitHub rows are the choice the audit ends in: a fine-grained token can name the repository, and
a classic one cannot.

## Build the inventory before you change a token

An inventory is a list of what each token can do, not what it is for. The secret store gives you
the names and the CI settings give you the rest. Every provider has a way to read a grant back.
A classic GitHub token answers any request with an
[`X-OAuth-Scopes` header](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps)
listing its scopes. A fine-grained one shows its permissions on its settings page, and an
[AWS access key](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html)
holds whatever policy is attached to its user.

```json title="inventory.json"
    {
      "id": "billing-export",
      "kind": "aws-access-key",
      "holder": "quarterly billing export job",
      "granted": ["s3:PutObject", "s3:GetObject", "s3:ListBucket", "s3:DeleteObject"],
      "issued": "2025-07-01",
      "cadence_days": 90
    },
```

`cadence_days` is the field that stops the audit from lying to you. It records how often the
holder runs, and a week of logs says nothing about a job that runs once a quarter. Write it down
while the person who knows is still in the room.

## Map a week of calls to the scopes they needed

Each log line names a token, a method, and a path. The route table turns the pair into the scope
that call needed, one table per token kind, first match wins.

```js title="scopes.mjs"
  'github-classic': [
    ['POST', /^\/repos\/[^/]+\/[^/]+\/actions\/workflows\/[^/]+\/dispatches$/, 'workflow'],
    ['POST', /^\/repos\/[^/]+\/[^/]+\/deployments$/, 'repo'],
    ['PUT', /^\/repos\/[^/]+\/[^/]+\/contents\//, 'repo'],
    ['GET', /^\/repos\/[^/]+\/[^/]+(\/|$)/, 'repo'],
    ['GET', /^\/orgs\/[^/]+\/members$/, 'admin:org'],
    ['DELETE', /^\/repos\/[^/]+\/[^/]+$/, 'delete_repo'],
  ],
```

The audit joins the inventory to the calls and prints a verdict per token.

```bash
node audit.mjs
```

```text output
log window 2026-09-14 to 2026-09-20, 7 days, 32 calls, 0 on routes the table does not know

token           kind                 granted  used  unused                         verdict
ci-deploy       github-classic       4        2     admin:org, delete_repo         narrow to repo, workflow
metrics-sync    github-fine-grained  4        3     pull_requests:write            narrow to contents:read, issues:write, metadata:read
billing-export  aws-access-key       4        3     s3:DeleteObject                wait: 7 of 90 days observed
docs-bot        github-classic       3        1     gist, notifications            narrow to repo
```

Three tokens get a narrower scope set and one gets told to wait. `billing-export` never called
`s3:DeleteObject` in the window, and the audit refuses to call that unused, because the holder
runs every ninety days and the window is seven. The number on the end of the first line matters
as much as the table. A call on a route the table has never heard of is counted there, not
treated as a scope nobody used.

The verdicts are what the logs support, and the logs are the weak link. A week is enough for a
daily job and useless for a quarterly one, and the store keeps a longer memory than your log
retention does.[^1]

## Re-issue narrow, keep broad, flip a flag

Issue the narrow token before you revoke anything, and put both in the secret store. The client
holds the pair and a flag picks which one it sends.

```js title="client.mjs"
      const token = narrow ? set.narrow : set.broad
      const res = api.handle({ token, method, path })
      if (res.status === 403 && res.error === 'insufficient_scope') {
        onDenied({ token: id, required: res.scope, method, path, narrow })
        throw new Error(`${id} needs ${res.scope} for ${method} ${path} (narrow=${narrow})`)
      }
```

The alert fires before the throw, and it carries the token, the scope, and the call. That is the
difference between a job that stopped and a job that told you why. The demo runs the integration
suite four times: broad, narrow, narrow with one token cut too far, and broad again.

```bash
node narrow.mjs
```

```text output
broad tokens, the flag unset
  broad: 8 of 8 calls succeeded
narrow tokens, NARROW_TOKENS=1
  narrow: 8 of 8 calls succeeded
ci-deploy narrowed one scope too far, to repo alone
  denied   ci-deploy needs workflow for POST /repos/acme/api/actions/workflows/deploy.yml/dispatches (narrow=true)
  narrow: 7 of 8 calls succeeded
  alert    token=ci-deploy required=workflow path=/repos/acme/api/actions/workflows/deploy.yml/dispatches
the flag flipped back, nothing re-issued
  broad: 8 of 8 calls succeeded
```

The third run is the one to keep in mind. `ci-deploy` was cut to `repo` alone, the workflow
dispatch was refused, the alert named `workflow`, and the fourth run recovered by flipping the
flag. Nothing was re-issued, so nobody waited for a token to be minted while the release sat
there.

## Check it worked

Narrow a token to the scopes you believe it needs and run the integration suite against it. That
is the only proof of a least-privilege scope set, because the thing still working is the
definition of enough.[^2]

```bash
node --test integration.test.mjs
```

```text output
1..5
# tests 5
# suites 0
# pass 5
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 94.916191
```

The first test runs every call the suite makes with `narrow` set and asserts `200` on each. The
second cuts `ci-deploy` too far and asserts the failure names `workflow` and the alert fired
once. The fourth runs the audit and asserts `billing-export` is told to wait, with
`s3:DeleteObject` unused and not condemned.

## When it goes wrong

The narrow token fails on a call the audit never saw. The log window was shorter than the
holder's cycle, or the log did not cover every instance. Flip the flag back, widen the window,
and run the audit again.

The audit marks every scope of a token unused. The log lines carry a token id the inventory does
not know, because the logs hold a hash or a prefix and the inventory holds a name. Join on the
id the provider prints, which for a GitHub token is the prefix and the first characters, and
store that beside the name.[^3]

The denial arrives as a 404 and the alert never fires. RFC 6750 says a server should answer 403
and may name the scope, and a provider that answers 404 to hide a resource's existence leaves
the alert with nothing to match. Alert on the call that started failing after the flag flipped,
not on the status code alone.

## When not to do this

Do not revoke a scope because a week of logs never saw it used. A job that runs once a quarter
looks unused for eighty-three days out of ninety, and the audit's `wait` verdict exists for it.
Watch a full cycle, and alert on the denial rather than deleting in silence.

Do not delete the broad token the day the narrow one passes the suite. Keep the pair for one
full cycle of every holder, then revoke the broad one, and treat the revocation as its own
change with its own rollback.

Do not narrow anything you cannot see being refused. An alert on `insufficient_scope` is the
precondition for the whole exercise, because a denial nobody sees is a job that stopped for a
reason nobody knows.

Do not treat many narrow tokens as free. Each one is an expiry to track, a rotation to schedule,
and a secret to store, and an organization that forces a
[maximum lifetime](https://docs.github.com/en/organizations/managing-programmatic-access-to-your-organization/setting-a-personal-access-token-policy-for-your-organization)
turns that into a calendar. Where a job can federate, a
[role assumed for one job](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRoleWithWebIdentity.html)
has no token to audit at all, which is the better answer to the whole page.

## Related how-tos

- [Rotate API keys without breaking your clients](/howto/rotate-api-keys-without-breaking-clients)

- [Keep test and live API keys from crossing environments](/howto/keep-test-and-live-keys-apart)

- [Diagnose a 401 or a 403 from a credential](/howto/diagnose-401-and-403-from-a-credential)

## Last verified

Verified 2026-09-24 against Node 22.22.2. Every output block is what the preceding command
printed. GitHub and AWS are stood in for by the route table and the resource server in the
sample directory. Neither service was called, and the scopes in the table come from the
documentation linked beside each.

[^1]: AWS keeps [last-accessed information](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_last-accessed.html)
for a service for at least 400 days, and the page gives the date each service began recording
it, Amazon S3 in April 2020. An
[unused access analyzer](https://docs.aws.amazon.com/IAM/latest/UserGuide/access-analyzer-create-unused.html)
takes a tracking period from 1 to 365 days and evaluates only permissions that have existed for
the whole of it. Four hundred days is a longer memory than most log retention policies, and a
quarterly job fits inside it four times over, which a week does not manage once.

[^2]: The phrase has an author. Saltzer and Schroeder set out the principle of least privilege in
[The Protection of Information in Computer Systems](https://web.mit.edu/Saltzer/www/publications/protection/Basic.html)
in 1975. Every program and every user, it says, should operate using the least set of privileges
necessary to complete the job. The paper gives the reason in the next sentence: the principle
limits the damage from an accident or error. It lists seven other principles beside it that
receive less quotation.

[^3]: GitHub's tokens start with a prefix that says what they are, `ghp_` for a personal access
token among them. The company
[introduced the format in 2021](https://github.blog/engineering/behind-githubs-new-authentication-token-formats/)
so that its secret scanning could recognize a token in a commit. The prefix was chosen for
machines reading leaks, and it turns out to serve an inventory just as well. The same
[documentation](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens)
adds that GitHub removes a personal access token that has not been used in a year, which is an
audit with a fixed window and no appeal.