Blog

API Token Rotation: Retire Long-Lived Static Keys

·6 min read

The tokens were valid. That was the problem.

In May 2026, GitHub confirmed that a threat actor had cloned roughly 3,800 of its own internal repositories using stolen credentials. Reporting from Sophos and the Nx Console postmortem describes the same skeleton: a malicious VS Code extension harvested GitHub OAuth tokens, SSH keys, and cloud credentials from a developer machine. The tokens worked. They kept working. In Nx’s case the stolen gh CLI token was live in their GitHub organisation for seven days before anyone noticed. It was later noted that to the group behind a lot of this activity, TeamPCP, predates the AI era – the tokens were the prize, not the novelty.

Nothing exotic had to be broken in Git’s protocol. Long-lived tokens, sitting in ~/.config/gh/hosts.yml, environment variables, and process memory, did what long-lived tokens do. They authenticated whoever presented them, for as long as nobody rotated them. The extension did not need to crack GitHub. It needed to read a file.

The downstream version of the same skeleton showed up in June. Attackers used a dormant Klue credential, stole customer OAuth tokens, and walked Salesforce CRM data out of connected tenants (e.g. the Icarus / Klue OAuth breach). The tokens were valid. That was the problem.

That pattern is not unique to IDEs or to one SaaS integration. It is how most outbound API access still works. A vendor key in an agent env file. An OAuth token a script refreshed once in 2024. A shared integration secret copied into a twelfth Lambda. Tokens do not expire themselves. They wait.

Static keys sprawl because issuance is local

The reason these credentials stay long-lived is that issuance is a side effect of getting the integration working. Someone generates a PAT, pastes it into a config, and ships. There is no central record of who received a copy. There is no default expiry. Rotation means finding every paste, which is why rotation does not happen.

Agents make the sprawl worse. Each new workflow wants “the GitHub token” or “the Stripe key.” The fastest path is to reuse the one that already works. The result is a single static secret with the union of every workflow’s scope, held by every process that ever needed a subset of it. Compromise one laptop, one extension, one verbose log, and the blast radius is the union.

Secrets managers store that secret more safely. They do not stop every caller from holding a working copy, they do not scope what each caller may do with it, and they do not rotate the copy the agent already loaded into memory last Tuesday.

Issue a caller credential. Keep the vendor credential.

The split that changes the default is two credentials, not one.

The target credential is the vendor secret – the GitHub PAT, the Stripe key, the CRM token. It lives in one place, encrypted, and is attached to the upstream request only after policy allows the call. Callers never receive it. Rotating it is an update to that one record.

The proxy credential is what the caller actually holds: a distinct key bound to one proxy, which is bound to one target, which has a deny-by-default rule set. Compromise of the proxy credential is compromise of that caller’s grant, not of the vendor account.

Create them as separate objects (POST /clients/{clientId}/credentials):

{
    "credentialType": "proxy",
    "credentialAuthType": "key",
    "credentialName": "docs-agent-github",
    "credentialRegion": "us-east-1",
    "credentialSecret": {
        "key": "Authorization",
        "value": "rr_live_docs_agent_xxxxxxxxxxxxxx",
        "addToHeader": true
    },
    "credentialNotes": "Docs agent only – never the upstream GitHub PAT"
}

The agent configuration contains the proxy key. It does not contain ghp_. A malicious extension that scrapes the agent’s environment gets a credential that can only call the paths that proxy allows – for example GET on a single repository’s contents, not POST to create a deploy key and not clone-every-repo. Rotate the GitHub PAT by updating the target credential. Confirm traffic. The agent’s config does not change.

That is the operational meaning of “time-bound” when the vendor’s own token is a static PAT: you can rotate on a schedule because rotation is no longer a redeploy. For vendors that issue OAuth2 access tokens, the gateway holds the refresh material and presents short-lived bearer tokens upstream. The agent still never sees them. The TTL field on a credential record is part of that OAuth2 handshake, not a general-purpose expiry you set on an API key – do not treat it as one.

Revoke the caller, not the estate

When a laptop, an extension, or an agent is suspected, you revoke that caller’s proxy credential. You do not have to rotate the vendor key as the first move, and you do not have to hunt for every copy of a shared secret. One credential, one proxy, one grant.

If the vendor token itself may have leaked – because it used to live in the same env file – then you rotate the target credential and treat every old copy as burned. The point of the split is that this should be a rare event, not the default incident-response plan.

Shared static keys make the opposite true. GitHub’s internal clone storm and Nx’s seven quiet days were both “the token still worked.” A token that still works after it has left the machine it was issued to is not a credential. It is a spare key under the mat.

What this does not fix

It does not make a proxy key immune to theft. A proxy credential is still a bearer secret. If the docs agent holds it, malware on that host can use it, within the proxy’s rules, until you revoke it. Scope and a meter are what keep that window from becoming 3,800 repositories. Short-lived, automatically expiring proxy keys are not what this control point issues today – if you need that property, issue a fresh proxy credential per job and delete it when the job ends.

It also does not replace GitHub’s own fine-grained PATs, SSO, or audit log. Use those. Then stop putting the PAT on the box that runs the agent.

Next steps

Inventory the outbound vendor tokens currently sitting in agent configs, CI variables, and developer home directories. For each one, create a target credential, a per-caller proxy credential, and a deny-by-default proxy. Move the vendor secret out of the environment. Put a rotation date on the target credential that you can actually keep, because keeping it no longer requires a release.

This is post 4 of APIs You Don’t Own. The policy half of the same credential is least privilege at the call. When a token is used anyway, you need an audit trail that already exists. RequestRocket is runtime access control for AI agents and apps calling APIs you don’t own – every call gets a least-privilege credential, a policy check, and an audit record, with no code changes. Read the documentation, or start for free.

Enhance ISO 27001
Enhance SOC 2
Enhance GDPR
Enhance HIPAA

Add outbound API security
without changing code

Start on your own or talk to our team about improving the security of every API call you make.