How-to › Release and secure packages

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.

Audience
Platform team
Level
intermediate
Topic
Harden clients and servers
Verified

The deploy job’s token can delete every repository in the organization, because somebody ticked every box two years ago and nothing has failed since. Nobody knows which boxes it needs. Narrow it and the release stops on a Friday; leave it and one leaked secret is the whole organization. The same is true of the eleven other tokens in the secret store.

What you get

You will end up with an inventory of every long-lived token, a report of the scopes a week of calls touched, and a client that picks narrow or broad by flag. This is for you if you run the CI and the services that hold the tokens.

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 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 and the classic scopes, and an AWS action names its own permission.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
AWS IAM Access Analyzer unused access findingsAWS roles and access keys, where you want the evidence gathered for you over a tracking period you setA paid analyzer per account, findings for AWS credentials alone, and a tracking period that starts when you create itThe tokens are for GitHub, Stripe, or anything outside AWS
GitHub fine-grained personal access tokensScopes per repository and per permission, read or write, with an owner who can require approvalA token per owner, endpoints the fine-grained list leaves out, and an expiry an organization can forceThe job needs an endpoint the list does not cover, or spans organizations
GitHub personal access tokens (classic)Every endpoint, every organization the user is in, and the X-OAuth-Scopes header to read the grant backScopes such as repo cover every repository the user can see, so the smallest grant is still largeYou can name the repositories the job touches
OIDC federation from CI to a cloud roleCI jobs that reach a cloud provider, where a per-job token replaces a stored keyA trust policy and a role per job, and nothing for the APIs that take no federated tokenThe 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 listing its scopes. A fine-grained one shows its permissions on its settings page, and an AWS access key holds whatever policy is attached to its user.

    {
      "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.

  '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.

node audit.mjs
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.

      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.

node narrow.mjs
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

node --test integration.test.mjs
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 turns that into a calendar. Where a job can federate, a role assumed for one job has no token to audit at all, which is the better answer to the whole page.

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.

Footnotes

  1. AWS keeps last-accessed information 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 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. ↩︎ Back to text

  2. The phrase has an author. Saltzer and Schroeder set out the principle of least privilege in The Protection of Information in Computer Systems 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. ↩︎ Back to text

  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 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 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. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.