Back to glossary

OAuth

OAuth is the IETF authorization framework that lets an app act on your account with a scoped access token instead of your password.

Also known as Open Authorization, OAuth 2.0, OAuth 2.1, OAuth2

OAuth (Open Authorization) is an IETF framework that lets one application act on a user's account at another service without seeing the password. The user approves a scoped request, and an authorization server issues the client an access token, usually a bearer token. Whoever holds that token holds the access, which makes OAuth tokens prime targets for theft.

As of November 2026, the deployed version is OAuth 2.0, published as RFC 6749 in October 2012. OAuth 2.1, which folds in a decade of security fixes, is still an IETF draft: revision 17 is dated October 31, 2026. The protocol has held up well. What fails in practice is custody of the tokens it produces. An OAuth grant mints a credential that lives in someone else's database or on a developer's disk, often outlives a password reset, and never sees an MFA prompt. The npm registry's own infrastructure was breached this way in 2022, and Vercel's internal systems in 2026.

Our position: treat every OAuth grant as a long-lived password you have handed to a third party. Inventory grants the way you inventory dependencies, prefer clients that receive short-lived tokens, and assume any token on a machine that runs npm install can be read by a malicious package. Most OAuth explainers stop at the redirect flow. The incidents start after it.

What is OAuth?

#

OAuth is a delegation protocol: it lets a user grant an application limited access to their account on another service. Before OAuth, a site that wanted your contacts asked for your email password and logged in as you. That gave the site full access, no scoping, and no way to revoke it short of changing the password.

RFC 6749 replaces that pattern with four roles. The resource owner is the user. The client is the application asking for access. The authorization server authenticates the user, shows a consent screen, and issues tokens. The resource server is the API that accepts those tokens. The client never handles the user's password. It receives an access token that stands for a specific set of scopes, a lifetime, and usually a single API.

OAuth is authorization, not authentication. An access token says what its holder may do, not who is at the keyboard. "Sign in with Google" is OpenID Connect, an identity layer that adds a signed ID token on top of an OAuth 2.0 flow. Many products blur the two, which is how teams end up accepting an access token as proof of identity.

The design choice that matters most for security came in the jump from version 1 to version 2. OAuth 1.0 (RFC 5849, April 2010) made clients sign every API request with a secret, so a captured request was not enough to make new ones. OAuth 2.0 dropped request signing and relied on TLS plus bearer tokens. That made OAuth easy to implement in any language, and it made the token string the entire credential.

How does OAuth work?

#

OAuth works by sending the user's browser to the authorization server, which returns a one-time code that the client exchanges for tokens over a direct server-to-server call. This is the authorization code grant. Since RFC 7636 (September 2015) it is paired with PKCE, Proof Key for Code Exchange, which ties the code to the client that asked for it.

The figure above shows nine steps across four parties. Steps 1 to 5 run through the browser, the front channel. (1) The client redirects the browser to the authorization server's /authorize endpoint with its client_id, the requested scope, a random state value, and a code_challenge. (2) The browser loads the authorization server's page. (3) The user logs in, completes MFA if required, and approves the scopes. (4) The authorization server redirects the browser to the client's registered redirect_uri with a single-use code and the same state. (5) The browser delivers the code to the client, which checks that state matches. Steps 6 to 9 are direct TLS calls, the back channel. (6) The client posts the code and its code_verifier to /token. (7) The server returns an access_token and often a refresh_token, which the client must now store. (8) The client calls the API with Authorization: Bearer <access_token>, with no password and no MFA involved. (9) The API returns data limited to the token's scopes. Only steps 7 and 8 carry the token.

The back-channel response in step 7 is plain JSON. This example is the one in RFC 6749 section 4.1.4:

JSON
{
  "access_token": "2YotnFZFEjr1zCsicMWpAA",
  "token_type": "example",
  "expires_in": 3600,
  "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA",
  "example_parameter": "example_value"
}

The access token is short-lived here, one hour. The refresh token is the durable part: the client trades it for new access tokens without involving the user, and it is never sent to the API. Whoever stores the refresh token holds the grant for as long as the authorization server honors it.

What PKCE protects, in five lines

#

PKCE exists because the code in step 4 travels through URLs, browser history, and on mobile, custom URL schemes that another app can register. The client generates a random code_verifier, sends its SHA-256 hash as the code_challenge in step 1, and reveals the verifier only in step 6. You can check the transform against the test vector in RFC 7636 Appendix B:

Bash
verifier='dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'
printf %s "$verifier" | openssl dgst -sha256 -binary \
  | openssl base64 -A | tr '+/' '-_' | tr -d '='
# E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM

We ran this and got the challenge the RFC lists. An attacker who intercepts the code cannot redeem it without the verifier, which never left the client. PKCE does nothing once tokens are issued. It protects the trip between steps 4 and 6, not the months that follow.

OAuth grant types and when to use each

#

OAuth 2.0 defines several ways to obtain a token, and the IETF has since retired two of them. RFC 9700, the OAuth 2.0 Security Best Current Practice published in January 2025, sets the current rules. The OAuth 2.1 draft codifies them by leaving the retired grants out entirely.

GrantTypical clientStatus as of November 2026Main risk
Authorization code + PKCEWeb apps, SPAs, mobile and desktop appsDefault. RFC 9700: "Public clients MUST use PKCE"Token custody after issuance
Device authorization (RFC 8628)CLIs, TVs, the GitHub CLIStandard since August 2019Remote phishing: attacker starts the flow, victim enters the code
Client credentialsService-to-service jobsStandardA leaked client secret lets anyone mint tokens
Refresh tokenAny client that needs offline accessPublic clients: "MUST be sender-constrained or use refresh token rotation"Durable access if stolen
ImplicitLegacy browser appsRFC 9700: SHOULD NOT. Not in OAuth 2.1Access token returned in the URL
Resource owner password credentialsLegacy first-party appsRFC 9700: MUST NOT. Not in OAuth 2.1The client sees the password, which defeats the point

Verdict: use the authorization code grant with PKCE for anything that can open a browser, including native apps. Use the device grant only for clients that cannot, and teach users that an unexpected device code is a phishing attempt. Use client credentials for machines, and replace the static secret with workload identity federation where the platform supports it. Do not ship new code on the implicit or password grants.

Why is a stolen OAuth token as good as a password?

#

A stolen OAuth token is as good as a password because the common token type is a bearer token, and possession is the only check. GitHub's and Google's APIs both take tokens in an Authorization: Bearer header. RFC 6750, written by Michael Jones and Dick Hardt in October 2012, defines the property directly: "Using a bearer token does not require a bearer to prove possession of cryptographic key material." Four consequences follow, and each one shows up in the incidents below.

MFA does not apply. Multi-factor authentication protects the login in step 3, and the token is issued after the login. A replayed token skips the login entirely, so MFA is never asked. Our research on the JeetBot Twitch extension put it plainly: whoever holds the token "can act on the account without the password or a second factor."

A password reset often does not revoke grants. Google documents that a password change invalidates a refresh token only when the token contains Gmail scopes. A refresh token granted for Drive or Calendar access keeps working after the user resets the password. Among Google's other conditions, a refresh token stops working after six months of non-use or when the user or an admin revokes the app.

Lifetimes are long by default. Existing GitHub OAuth apps issue gho_ tokens that do not expire unless the app opts in. GitHub's own changelog describes a leaked GitHub CLI token as "lasting indefinitely", and GitHub revokes tokens only after a year without use. GitHub let OAuth apps opt into expiring tokens on August 14, 2026: an eight-hour access token and a refresh token valid for six months, on by default only for newly registered apps.

Scopes are coarse. On GitHub, the repo scope "grants full access to public and private repositories," per GitHub's documentation, across every repository the user can reach. A token granted for one integration carries the user's whole footprint.

The protocol-level fix is sender-constraining: binding a token to a key the client holds, using mutual TLS or DPoP (RFC 9449, September 2023). RFC 9700 says servers SHOULD do it. Support is opt-in per authorization server, and none of the incidents in the next section involved a sender-constrained token.

How are OAuth tokens stolen in supply chain attacks?

#

OAuth tokens are stolen from wherever they are stored, and a single grant puts copies in several places. Attackers in recent supply chain incidents rarely broke an OAuth flow. They took tokens from storage, after a legitimate flow had already completed.

The figure above maps four places an OAuth token sits after a grant and the incidents that took tokens from each. The client vendor's backend holds refresh tokens for all of its users: Heroku and Travis CI tokens in April 2022, Salesloft Drift tokens in August 2025, and Context.ai tokens in March and April 2026. The Context.ai chain ran from Context.ai's AWS environment to a Vercel employee's Google token, into Vercel's Google Workspace, and on to customer environment variables. The developer's machine holds the GitHub CLI's gho_ token, which payloads in the Nx s1ngularity attack (August 2025), the TeamPCP-linked SAP CAP compromise (April 2026), and the Mini Shai-Hulud Red Hat wave (June 2026) read with gh auth token. The CI runner holds a short-lived OIDC token for trusted publishing, which the attacker in the TanStack compromise read from runner memory in May 2026. The browser holds session tokens that extensions can read, as the JeetBot Twitch extension did for about 31,000 users in September 2026.

The client vendor's token store

#

A client that stores refresh tokens for many users is a token vault. Breaching the vendor yields valid grants into every customer, and none of those customers did anything wrong after clicking "Allow."

The Vercel incident is the 2026 example. Context.ai, which ran a consumer AI office suite, says an unauthorized party accessed its AWS environment in March 2026 and "likely compromised OAuth tokens for some of our consumer users." One Vercel employee had signed up with a Vercel enterprise Google account and granted "Allow All" permissions. Vercel's bulletin, first published April 19, 2026, says the attacker used that access to take over the employee's Google Workspace account, then the employee's Vercel account, then decrypted environment variables that customers had not marked sensitive. The bulletin's indicator for Workspace admins is the OAuth client ID 110671459871-30f1spbu0hptbs60cb4vsmv79i7bbvqj.apps.googleusercontent.com. It also records that Vercel, together with GitHub, Microsoft, npm, and Socket, confirmed that no npm packages Vercel publishes were compromised.

The pattern is not new. In April 2022, an attacker used stolen OAuth tokens that GitHub had issued to the Heroku and Travis CI integrations to list and clone private repositories. One of those was npm's, and GitHub's postmortem says it held AWS access keys that let the attacker into npm's production infrastructure. In August 2025, Google's threat intelligence team reported an actor using Salesloft Drift's OAuth tokens to export data from "numerous corporate Salesforce instances" and then mine it for AWS keys and passwords.

The control that addresses the Vercel path is administrative, not a protocol feature. Google Workspace lets admins block unreviewed third-party apps and approve each one at a chosen access level under API controls. Context.ai's statement suggests Vercel's configuration allowed the broad grant. Allowlisting does not help when an approved vendor is breached, which is what happened with Heroku and Drift.

The developer's machine: gh auth token

#

The GitHub CLI is an OAuth app. gh auth login runs the device flow, receives a gho_ OAuth token, and stores it. Its own help text, from gh 2.89.0, describes the storage:

Plain text
The default authentication mode is a web-based browser flow. After completion, an
authentication token will be stored securely in the system credential store.
If a credential store is not found or there is an issue using it gh will fallback
to writing the token to a plain text file.
...
The minimum required scopes for the token are: `repo`, `read:org`, and `gist`.

Malware does not need to find the file or unlock the keychain. It asks gh. The Nx s1ngularity payload, which we analyzed when the malicious Nx versions appeared on August 26, 2025, did roughly this:

JavaScript
// Simplified from our analysis of the Nx (s1ngularity) payload
if (isOnPathSync('gh')) {
  const out = execSync('gh auth token').toString().trim()
  if (/^gh[op]_/.test(out)) result.ghToken = out
}
// then: POST /user/repos to create s1ngularity-repository in the victim's
// own account, and PUT the triple-base64 results.b64 into it

The Nx compromise ran from a postinstall script, according to the Nx advisory, so the token was gone before any application code loaded. Public data showed more than 1,000 victim accounts with attacker-created s1ngularity-repository repos. The same gh auth token call appears in the TeamPCP-linked SAP CAP compromise in April 2026 and in Mini Shai-Hulud payloads in June 2026. This is why we treat install scripts as the primary execution path for credential theft, and why npm v12, released in July 2026, turns dependency install scripts off by default. A malicious package that runs at import time or in a test still gets the token.

Once an attacker has a token, the same API that serves the user tells them what it can do. GitHub returns the token's scopes in a response header:

Bash
curl -sI -H "Authorization: Bearer $TOKEN" https://api.github.com/user \
  | grep -i '^x-oauth-scopes'
# lists the token's scopes, for example repo and read:org

GitHub is moving the CLI off long-lived tokens. On October 21, 2026, gh v2.103.0 added an opt-in for expiring, auto-refreshing credentials, gh auth login --expiring, with plans to make it the default for new sign-ins later. That blunts the Nx pattern, which posted tokens to public repositories where they could be used days later. It does less against malware that copies the credential store, because the rotating refresh token sits next to the access token. Until you opt in, the only way to rotate a gh token, as our Nx write-up noted, is to revoke the GitHub CLI's OAuth grant at github.com/settings/connections/applications/178c6fc778ccc68e1d6a, which kills every gh token for that account.

The CI runner: short-lived is not unstealable

#

Trusted publishing applies OAuth-style token exchange to package releases. A GitHub Actions job requests an OIDC token, and the registry exchanges it for a short-lived publish token. No long-lived npm token exists to steal from a repository secret.

The TanStack compromise in May 2026 showed the limit. Attacker-controlled code ran inside TanStack's workflow through a pull_request_target and cache-poisoning chain, read the OIDC token from the runner's memory, exchanged it for an npm publish token, and published malicious versions with valid provenance. Short lifetimes shrank the window to the length of one job. They did not move the trust boundary, which is any code that runs in a job holding id-token: write. That is a GitHub Actions security problem first and an OAuth problem second.

Code that sits next to a token can take it. The JeetBot extension forwarded each user's Twitch OAuth token in an &auth= query parameter to its operator's proxy, where it landed in request logs. A typosquat of the ScribeJava OAuth library on Maven Central, io.github.leetcrunch:scribejava-core, exfiltrated OAuth credentials on the 15th of each month. OAuth client libraries are valuable typosquat targets because they handle client secrets and tokens by design.

Attackers can also skip theft and get a fresh grant. In consent phishing, a malicious app requests broad scopes under a plausible name, and the consent screen the victim sees is genuine. The device grant is easier still to abuse, a risk RFC 8628 section 5.4 names: "an attacker might send an email instructing the target user to visit the verification URL and enter the user code." GitHub's documented device-code response shows what the attacker gets to work with:

JSON
{
  "device_code": "3584d83530557fdd1f46af8289938c8ef79f9dc5",
  "user_code": "WDJB-MJHT",
  "verification_uri": "https://github.com/login/device",
  "expires_in": 900,
  "interval": 5
}

The attacker sends the victim WDJB-MJHT and the real github.com URL, then polls the token endpoint every five seconds. If the victim enters the code within 15 minutes, the token goes to the attacker. Every URL in the flow is legitimate, so "check the address bar" training does not help.

What each OAuth control stops, and what it misses

#
ControlStopsDoes not stop
PKCEInterception and injection of authorization codesTheft of tokens after they are issued
Short-lived access tokens with refresh rotationReplay of an exfiltrated access token hours or days laterMalware that copies the refresh token, or uses the access token right away
Sender-constrained tokens (mTLS, DPoP)Replay from a different machineMalware running on the machine that holds the key; adoption is opt-in per server
Admin allowlisting of third-party appsUsers granting broad access to unreviewed apps, as in the Context.ai pathA breach of an approved vendor, as with Heroku, Travis CI, and Drift
Minimal scopesSome blast radiusCoarse scope models, such as GitHub's repo scope, that force over-granting
Blocking dependency install scriptsInstall-time harvesting of gh and npm tokensPayloads that run at import time, in tests, or in CI
Grant inventory and revocationDormant grants that outlive their purposeGrants you do not know exist

Least privilege applies to OAuth grants as much as to IAM roles, but scope models limit how far it goes. A user who wants a GitHub OAuth app to read one private repository has to grant repo, which covers all of them. GitHub Apps and fine-grained tokens exist to fix that, and they are the right choice for new integrations. The same pressure is reaching AI tooling: the June 2025 MCP specification revision made MCP servers OAuth resource servers, so every agent connector is now another client storing someone's tokens.

The open problem is that revocation, OAuth's only kill switch, depends on knowing which grants exist and who holds them. In each vendor breach above, the people who could revoke the stolen grants found out after the attacker had used them. Context.ai says it stopped the AWS intrusion in March 2026. Its conclusion that OAuth tokens were also stolen came in an April 19 update, based partly on information from Vercel. We think authorization servers should issue expiring, rotated, sender-constrained tokens by default, and that "Allow All" should not be a choice a single employee can make for an enterprise account. GitHub has made expiring tokens the default for new OAuth apps and left its own CLI opt-in. Until long-lived tokens stop being the default, assume every grant you have ever approved is still live somewhere.