Back to glossary

API Token

An API token is a secret string sent with each API request to prove identity and authority. Anyone who holds the token can use it.

Also known as Access token, Bearer token, Personal access token (PAT), Security token, npm token, GitHub token

An API token is a secret string, also called an access token, bearer token, or personal access token, that a client sends with each request to prove identity and permissions. Possession alone is enough: whoever holds the string gets its access. That property makes npm and GitHub tokens the primary target of supply chain worms.

An API token is a bearer credential, and bearer credentials are the fuel of every self-propagating package worm we have analyzed. Since Shai-Hulud appeared in September 2025, each wave has followed the same loop: run at install, collect tokens, use them to publish more infected packages. A stolen npm write token lets a worm republish every package within that token's reach. Our position is that hiding tokens better was never going to fix this. The fix is to stop any token, long-lived or short-lived, from publishing a release without a human second factor. npm is targeting January 2027 to enforce that for tokens that bypass 2FA. We think the same rule should apply to OIDC publishes by default.

What is an API token?

#

An API token is a string an API server issued earlier and now accepts as proof of who is calling and what they may do. The client usually sends it in the HTTP Authorization header. The server looks it up, or verifies its signature, and applies the permissions attached to it. No password, no interactive prompt, and no per-request proof of anything else is involved.

HTTP
GET /-/whoami HTTP/1.1
Host: registry.npmjs.org
Authorization: Bearer npm_<token>

RFC 6750, the 2012 specification for bearer tokens in OAuth 2.0, states the core property plainly: "Using a bearer token does not require a bearer to prove possession of cryptographic key material." Anyone holding a copy can present it. That is what makes API tokens convenient for scripts and CI jobs, and it is also the whole threat model.

API tokens come in two shapes. Opaque tokens, such as npm and GitHub personal access tokens, are random strings that mean nothing until the issuing server looks them up in its database. Self-contained tokens carry their own claims and a signature. The common format is the JSON Web Token (JWT): three base64url segments, header.payload.signature, where the server verifies the signature instead of querying a database. A JWT is still a bearer token. The signature proves who issued it, not who is presenting it.

Three neighboring terms get confused with API tokens:

  • API key. Usually a long-lived static secret that identifies an application or project rather than a user. In practice, most API keys behave exactly like bearer tokens and leak the same way.
  • Session cookie. A bearer token the browser stores and sends automatically. Same property, different transport.
  • Hardware security token. "Security token" also names a FIDO2 key or smart card that proves possession of a private key it never reveals. This is the opposite design: copying network traffic does not copy the credential.

Socket's own API follows the standard pattern. A Socket API token is sent as a Bearer header or as the username in HTTP Basic auth, with scopes chosen when the token is created.

How npm and GitHub tokens are structured

#

npm and GitHub tokens are opaque, but their formats are designed to be recognizable. In April 2021, GitHub added prefixes to its tokens and a 32-bit CRC32 checksum in the last six characters, encoded in Base62. npm adopted the same scheme for its tokens on September 23, 2021. GitHub estimated the prefix alone would cut the secret scanning false positive rate to 0.5%.

Plain text
npm_<random part><6-char CRC32>          npm token
ghp_<random part><6-char CRC32>          GitHub classic personal access token
github_pat_<...>                         GitHub fine-grained personal access token
gho_  ghu_  ghs_  ghr_                   OAuth, user-to-server, installation, refresh

The prefix tells a scanner what kind of credential it found. The checksum lets the scanner reject random strings offline, without calling the API. GitHub revokes a classic personal access token automatically when it is pushed to a public repository or gist.

Token prefixes cut both ways. The same npm_ and ghp_ patterns that help defenders find leaked tokens help malware sort its loot. In the Nx compromise, the payload ran gh auth token and kept the result only if it looked like a gho_ or ghp_ token. Shai-Hulud 2 scraped GitHub Actions metadata for any string starting with npm_. Both encoded their exfiltrated data with three layers of base64 before uploading it to GitHub, so GitHub's secret scanning never saw a raw token. Prefix matching only works on secrets the attacker leaves unencoded.

On disk, an npm token usually lives in one line of .npmrc, keyed to the registry host:

TOML, also INI
//registry.npmjs.org/:_authToken=${NPM_TOKEN}

The ${NPM_TOKEN} indirection keeps the token out of the repository, but it still lands in the process environment of every command that runs in that shell or CI job, including dependency install scripts.

How do worms steal and use npm tokens?

#

npm worms steal tokens by running inside a process that already holds them, then use each token to publish infected versions of everything that token can reach. The loop has five steps, and every downstream install restarts it.

The figure shows five stages. Install: a compromised version runs a preinstall or postinstall script and inherits every credential on the laptop or CI runner. Harvest: it reads NPM_TOKEN, GITHUB_TOKEN, ~/.npmrc, and the output of gh auth token, runs a TruffleHog sweep of $HOME, lists Actions secrets through the GitHub API, or requests an OIDC token from the runner. Validate: it calls GET /-/whoami with each token; 200 {"username":…} means the token works and 401 means it does not. Enumerate: it lists every package that identity can publish, up to 100 per token in Shai-Hulud 2. Republish: it downloads each tarball, injects the payload, bumps x.y.z to x.y.z+1, and publishes, so caret ranges pick up the new version. A side channel uses any GitHub token to create a public repository in the victim's own account and upload the stolen data triple base64 encoded.

The validation step is one HTTP request. This is the check from the original bundle.js, as transcribed in our analysis of the tinycolor compromise:

JavaScript
// npm token verification
fetch("https://registry.npmjs.org/-/whoami", {
  headers: { "Authorization": `Bearer ${process.env.NPM_TOKEN}` }
});

// GitHub API use if GITHUB_TOKEN is present
fetch("https://api.github.com/user", {
  headers: { "Authorization": `token ${process.env.GITHUB_TOKEN}` }
});

You can reproduce the failure case yourself. We ran this against the public registry on October 30, 2026:

Shell Session
$ curl -s -w ' %{http_code}\n' https://registry.npmjs.org/-/whoami
{"error":"Unauthorized"} 401

$ curl -s -w ' %{http_code}\n' \
    -H 'Authorization: Bearer npm_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' \
    https://registry.npmjs.org/-/whoami
{} 401

A valid token returns 200 with a JSON body containing username, which is the same field the npm CLI reads in get-identity.js. One request tells the worm whether a token is live and whose packages to look up next.

Tokens are not only on laptops. PostHog's compromise, one of the first in the Shai-Hulud 2 wave, started in CI: an attacker used a pull_request_target workflow to steal a bot's GitHub personal access token, then used that token to read the repository's Actions secrets, which included an npm publish token. A GitHub token with access to repository secrets is a key to every other token those secrets hold.

Why short-lived OIDC tokens did not stop the worm

#

Short-lived OIDC tokens shrink how long a stolen credential works, but they do not change who can use it during that window. Trusted publishing replaces a stored npm token with one minted per CI job. A GitHub Actions job with id-token: write can ask GitHub for a signed OpenID Connect (OIDC) JWT, and npm exchanges that JWT for a short-lived publish token scoped to one package.

The exchange is a few lines in the npm CLI. From lib/utils/oidc.js in npm 12.2.1:

JavaScript
const audience = `npm:${new URL(registry).hostname}`
const url = new URL(process.env.ACTIONS_ID_TOKEN_REQUEST_URL)
url.searchParams.append('audience', audience)
const response = await fetch(url.href, {
  headers: {
    Accept: 'application/json',
    Authorization: `Bearer ${process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN}`,
  },
})
idToken = (await response.json()).value

response = await npmFetch.json(
  new URL(`/-/npm/v1/oidc/token/exchange/package/${escapedPackageName}`, registry),
  { ...opts, [authTokenKey]: idToken, method: 'POST' })

Decoded, the JWT is a set of claims about the workflow run. This example follows the claim set in GitHub's OIDC documentation, with the npm audience the CLI requests:

Shell Session
$ python3 -c 'import sys,base64,json; p=sys.argv[1].split(".")[1]; \
  print(json.dumps(json.loads(base64.urlsafe_b64decode(p+"="*(-len(p)%4))),indent=2))' "$JWT"
{
  "sub": "repo:octo-org/octo-repo:ref:refs/heads/main",
  "aud": "npm:registry.npmjs.org",
  "repository": "octo-org/octo-repo",
  "job_workflow_ref": "octo-org/octo-repo/.github/workflows/release.yml@refs/heads/main",
  "event_name": "push",
  "runner_environment": "github-hosted",
  "iss": "https://token.actions.githubusercontent.com",
  "nbf": 1632492967,
  "exp": 1632493867,
  "iat": 1632493567
}

npm matches the token's claims, such as the repository and workflow, against the package's trusted publisher configuration. Nothing in the token says which code inside the job asked for it. Any process that can read ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN can request the same JWT.

That is what happened in the Mini Shai-Hulud compromise of TanStack on May 11, 2026. Attackers chained a pull_request_target workflow, GitHub Actions cache poisoning, and extraction of an OIDC token from the runner's process memory. TanStack's postmortem says no npm tokens were stolen. The 84 malicious artifacts were published through the project's legitimate trusted publisher binding, and the implant could attach valid Sigstore provenance to what it published. Socket's scanner flagged all of them within six minutes of publication. The lesson is not that OIDC is broken. A token minted for a job belongs to whatever code runs in that job. Provenance and trusted publishing are necessary, but they are not sufficient.

Which npm and GitHub tokens are riskiest?

#

The riskiest npm and GitHub tokens are long-lived write tokens that can publish without two-factor authentication (2FA). Lifetime matters less than what the token can do unattended, because a worm uses a token in the same run that finds it. As of October 2026:

CredentialPrefixLifetimeWhat code on the host can do with it
npm classic tokennpm_Revoked December 9, 2025Nothing. Classic tokens no longer exist.
npm granular token, write, bypass 2FAnpm_Up to 90 daysPublish every package in scope with no second factor, until npm's January 2027 cutoff.
npm granular token, write, 2FA requirednpm_Up to 90 daysPublishing prompts for a one-time password (OTP), which a worm does not have.
npm stage-only tokennpm_Up to 90 daysStage a version. It goes live only after a maintainer approves it with 2FA.
npm trusted publishing tokennone (exchanged JWT)Short-lived, one jobPublish the bound package from inside that job, including from injected code.
GitHub classic personal access token (PAT)ghp_No fixed expiryEverything its scopes allow, across every repository the user can reach.
GitHub fine-grained PATgithub_pat_Up to 1 year, or noneOnly the selected repositories and permissions.
GITHUB_TOKENnone listedEnds with the jobAct on the one repository, with the permissions the workflow grants.

Verdict: for npm, publish with trusted publishing routed through staged publishing, so the only thing a stolen token can do is queue a release that a human with 2FA must approve. Where a stored token is unavoidable, use a granular token scoped to specific packages with 2FA required, never bypass 2FA. For GitHub, replace classic PATs with fine-grained ones, and set permissions: explicitly on every workflow so GITHUB_TOKEN and id-token: write exist only in the job that needs them.

npm's token policy, 2025 to 2027

#

Since September 2025, npm has changed its token model at least seven times, and most changes followed an attack. The sequence shows which controls worked and which ones the next wave walked around.

The timeline, in order:

DateTypeEvent
2021-09-23Policynpm tokens gain the npm_ prefix and a CRC32 checksum.
2025-08-26AttackNx: a publish token is stolen through a workflow injection; over 1,000 accounts get exfiltration repos.
2025-09-15AttackShai-Hulud: the first self-propagating npm worm republishes packages with stolen tokens.
2025-10 to 2025-11-05PolicyNew write tokens get a 7-day default and 90-day maximum lifetime; classic token creation is disabled.
2025-11-24AttackShai-Hulud 2 runs at preinstall and mines npm_ strings out of Actions secrets.
2025-12-09PolicyClassic tokens are permanently revoked.
2026-05-11AttackTanStack: an OIDC token is taken from a runner; 84 artifacts, no long-lived token involved.
2026-05-18Attack@antv: 639 versions of 323 packages published from one hijacked account.
2026-05-19Policynpm invalidates every write token that bypasses 2FA.
2026-05-20PolicyStaged publishing enters public preview in npm CLI 11.15.0.
2026-07-08Policynpm v12 ships with dependency install scripts off by default.
2026-07-31PolicyBypass-2FA tokens can no longer manage tokens, maintainers, or trusted publishing.
2027-01 (target)PolicyBypass-2FA tokens lose direct publish; only staged publishes remain.

GitHub's September 2025 npm security plan capped new write tokens at 90 days, and the changelog announcing it said it directly: "Long-lived tokens are a primary vector for supply chain attacks." Revoking classic tokens forced overdue migrations. Invalidating every bypass-2FA write token on May 19, 2026, cut off tokens the worm had already collected, after the @antv compromise. Two days later, Socket counted 1,055 compromised versions across 502 packages in the Mini Shai-Hulud campaign, spanning npm, PyPI, and Composer. Maintainers issued new tokens, and the worm went back to harvesting them. A reset buys time. It does not remove the capability.

Two changes do remove capability. npm v12 stops dependency install scripts from running by default. Almost every npm worm since late 2025 ran at install time, so this removes the default path to the token. Default-on install scripts were a design mistake, and npm v12 corrects it. The gap is code that runs when a package is imported or executed, which no install setting can block. Staged publishing makes a release wait for a maintainer's interactive 2FA approval, which an OIDC token cannot provide. The GitHub changelog of July 31, 2026 targets January 2027 for stripping direct publish from bypass-2FA tokens.

How to limit what a stolen API token can do

#

Assume an API token will be stolen and design for what it can do afterward. Each control below narrows that, and each has a gap.

Scope the token to the minimum. npm granular tokens can be limited to specific packages and scopes, up to 50, and to IP ranges in CIDR notation, according to npm's token documentation. Scoping limits the blast radius, but not inside the scope: a token for one popular package is still enough to poison that package.

Keep publish authority out of jobs that run untrusted code. Set id-token: write only on the publishing job, never at workflow level, and never in a GitHub Actions workflow triggered by pull_request_target. This does not help if the publish job itself installs a compromised dependency.

Require a human for every release. Staged publishing, or a 2FA-required token, means a stolen credential can at most queue a release. The cost is real friction for maintainers with hundreds of packages, which is why npm shipped staged publishing as opt-in per package.

Do not let project files choose where a token is sent. pnpm 11.10 added an _auth setting that binds each token to its registry URL and is read only from global config or the environment. A malicious .npmrc in a cloned repository can no longer point a valid token at an attacker's host. The setting protects pnpm users only.

Rotate everything the compromised process could read, not just npm. The Mini Shai-Hulud payloads also took GitHub tokens, cloud keys, SSH keys, Kubernetes and Vault tokens, and AI tool configuration files. Rotating only the npm token leaves the attacker the GitHub token that can read your other secrets. If one of your own maintainers was the entry point, treat it as a maintainer account takeover and audit recent publishes and commits.

The open problem is that every one of these controls still treats a token as a publish button for whoever holds it. Until a release requires a fresh human second factor by default, not by opt-in, the next worm only needs to run inside the right job once.