ZIV LABS
Engineering notes

OAuth grants and leaked tokens

An OAuth grant is standing access: whatever a third-party app was allowed to do, anyone who takes over that app can do too. In April 2026 Vercel disclosed an incident that, by its own account, began with a compromise of a third-party AI tool whose Google Workspace OAuth app was used by a Vercel employee. This note keeps to what Vercel's bulletin states, then runs four small local simulations of the controls that decide how far a leaked grant or code can go: PKCE, exact redirect URI matching, narrow scopes and audiences, and where secrets are kept. Everything ran on Node.js v24.7.0, with no network access.

What Vercel reported

These points come from Vercel's own bulletin, first published on April 19, 2026 and last updated on October 16, 2026, when Vercel states that remediation is complete and the incident is closed. The incident originated with a compromise of Context.ai, a third-party AI tool used by a Vercel employee. The attacker used that access to take over the employee's Vercel Google Workspace account, then the employee's Vercel account, and from there reached a Vercel environment and read non-sensitive environment variables, meaning those that decrypt to plaintext. Vercel's investigation traced the start to the tool's Google Workspace OAuth app, which it describes as the subject of a broader compromise potentially affecting hundreds of users across many organizations. Vercel first identified a limited set of affected customers, later a small number of additional accounts, and says it found no compromise of the npm packages it publishes.

Vercel recommends multi-factor authentication, reviewing and rotating environment variables that were not marked sensitive, using its sensitive environment variable feature for secrets, checking activity logs and deployments, and setting Deployment Protection to at least Standard. We did not read Context.ai's own statements or the press coverage, and we do not repeat them here.

What the bulletin does not tell us is the technical step between the compromised OAuth app and the employee's account: which grants, tokens, or scopes were involved, and how. So we cannot say which of the controls below would have mattered in this incident, and we do not claim they would have. They are the standard controls from the OAuth specifications, tested here on their own.

1. PKCE: a stolen authorization code is not enough

In the authorization code flow, the client sends the user to the authorization server and gets back a short-lived code, which it exchanges for tokens. If someone else obtains that code, they could try to exchange it first. PKCE (RFC 7636) binds the code to a secret the client keeps: the client sends a hash of a random code_verifier, the code_challenge, with the request, and must reveal the verifier itself when exchanging the code. RFC 7636 computes the S256 challenge as the base64url encoding of the SHA-256 hash of the verifier (section 4.2) and recommends a verifier with 256 bits of entropy (section 7.1). Our implementation reproduces the test vector in its appendix B. Then a mock authorization server shows the effect:

1. RFC 7636 appendix B test vector
  expected E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  computed E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  match: true
2. PKCE: a stolen authorization code
  attacker with the code, no verifier
    -> denied: code_verifier does not match
  attacker guesses a verifier
    -> denied: code_verifier does not match
  legitimate client with its verifier
    -> ok ({"access_token":"at_demo"})

The attacker who holds only the code, or who guesses a verifier, is refused; the client with the verifier it generated succeeds. This protects the code exchange. It does not protect an access token or refresh token that is stolen after it was issued, and it does nothing if the compromised party is the client app itself, because that app holds the verifier.

2. Do not let the server accept a code without PKCE

RFC 9700, the OAuth 2.0 security best current practice, requires public clients to use PKCE and recommends it for confidential clients as well (section 2.1.1). It also says the authorization server must enforce the verifier whenever a challenge was sent, and must prevent downgrade: a token request that carries a verifier is accepted only if the authorization request carried a challenge. The same section says S256 is the method that does not expose the verifier in the authorization request. Where PKCE is optional, the protection is optional for an attacker too:

3. PKCE not enforced
  code issued without a challenge, attacker has the code
    -> ok ({"access_token":"at_demo"})
  same request to a server that requires PKCE
    -> denied at authorize: code_challenge required
  verifier sent although no challenge was registered
    -> denied: verifier without a registered challenge (downgrade)

A code issued without a challenge works for whoever presents it. A server that requires the challenge refuses to issue it, and one that sees a verifier with no registered challenge refuses the exchange.

3. Redirect URIs: compare exact strings

The authorization server sends the code to the redirect_uri. If the server accepts anything that merely resembles the registered URI, an attacker can send the code to an address they control. RFC 9700 section 2.1 requires exact string matching against the pre-registered URIs, with one exception for port numbers on localhost redirects of native apps. Here are four comparison methods against the registered value https://app.example/callback, using reserved example domains that do not resolve to anything:

4. Redirect URI comparison (registered: https://app.example/callback)
  startsWith(origin)
    accepts https://app.example/callback
    accepts https://app.example.attacker.test/callback
    accepts https://app.example/callbackx
    rejects https://appxexample/callback
  startsWith(full URI)
    accepts https://app.example/callback
    rejects https://app.example.attacker.test/callback
    accepts https://app.example/callbackx
    rejects https://appxexample/callback
  regex, dots unescaped
    accepts https://app.example/callback
    rejects https://app.example.attacker.test/callback
    rejects https://app.example/callbackx
    accepts https://appxexample/callback
  exact string match
    accepts https://app.example/callback
    rejects https://app.example.attacker.test/callback
    rejects https://app.example/callbackx
    rejects https://appxexample/callback

Only the exact comparison rejects all three lookalikes. Matching on the origin accepts a different host that begins with the same text, matching on the start of the full URI accepts a longer path, and an unescaped dot in a regular expression accepts a different host name. These are the pitfalls of building a pattern; the specification's answer is not to use one.

4. Narrow grants and audiences limit what a leak is worth

RFC 9700 section 2.3 says an access token's privileges should be the minimum the use case needs, that it should be restricted to a specific resource server, and that each resource server must verify, on every request, that the token was meant for it and for the action. A resource server that checks both:

5. What a leaked token can do
  narrow token (projects:read)
    read projects: allowed
    read secrets:  denied: missing scope secrets:read
    replay at the billing API: denied: wrong audience
  broad token (projects:read projects:write secrets:read)
    read projects: allowed
    read secrets:  allowed
    replay at the billing API: denied: wrong audience

A token with only projects:read cannot read secrets, and neither token works at a different service. The broad token can read secrets because someone granted that scope. In an OAuth grant, that decision is made when the user or administrator clicks Allow, not when the token leaks. RFC 9700 also recommends sender-constrained tokens (mutual TLS or DPoP, section 2.2.1), which we did not test.

5. Where the secrets live

Vercel's bulletin describes data that was readable because it was stored as an ordinary, non-sensitive environment variable, and its advice is to rotate those values and use the sensitive setting for secrets going forward. The general lesson is about the blast radius of one compromised account: a secret that the account's owner could read is a secret an attacker with that account could read. We did not test this part; it is Vercel's recommendation, and the details depend on the platform you use.

Checklist

  • List the third-party apps and AI tools that have OAuth grants on your Google Workspace, GitHub, or similar accounts. Remove the ones nobody uses, and review the scopes of the rest.
  • Prefer narrow scopes, and ask who decides when a tool requests broad ones. A grant is standing access until someone revokes it.
  • If you run an authorization server or a client: require PKCE with S256, enforce the verifier, reject downgrade, and compare redirect URIs as exact strings.
  • Restrict tokens to one audience and verify scope and audience on every request at the resource server.
  • Require multi-factor authentication on the accounts that can read your secrets.
  • Store secrets in the place your platform marks as sensitive or secret, and know which ones an account can read in plaintext. Be ready to rotate them.

Reproduce it

Save these four files in one folder and run node demo.mjs. The output is deterministic, nothing is sent over the network, and the server is an in-memory mock.

pkce.mjs
import { createHash, randomBytes } from "node:crypto";

// RFC 7636 section 4.2: code_challenge = BASE64URL(SHA256(ASCII(code_verifier)))
export const s256 = (verifier) => createHash("sha256").update(verifier, "ascii").digest("base64url");
export const newVerifier = () => randomBytes(32).toString("base64url"); // 43 characters, 256 bits (section 7.1)

// A minimal authorization server. It only keeps what PKCE needs: the code, the challenge sent with it, and the redirect URI.
export class AuthServer {
  constructor({ requirePkce }) {
    this.requirePkce = requirePkce;
    this.codes = new Map();
  }
  authorize({ redirectUri, challenge, method }) {
    if (this.requirePkce && !challenge) throw new Error("denied at authorize: code_challenge required");
    if (challenge && method !== "S256") throw new Error("denied at authorize: only S256 accepted");
    const code = randomBytes(8).toString("hex");
    this.codes.set(code, { redirectUri, challenge });
    return code;
  }
  token({ code, verifier }) {
    const entry = this.codes.get(code);
    if (!entry) throw new Error("denied: unknown or used code");
    this.codes.delete(code); // single use
    if (entry.challenge) {
      if (!verifier || s256(verifier) !== entry.challenge) throw new Error("denied: code_verifier does not match");
    } else if (verifier) {
      throw new Error("denied: verifier without a registered challenge (downgrade)");
    }
    return { access_token: "at_demo" };
  }
}
redirect.mjs
const REGISTERED = "https://app.example/callback";

// Four ways to compare the redirect_uri of an authorization request with the registered one.
export const matchers = {
  "startsWith(origin)": (uri) => uri.startsWith("https://app.example"),
  "startsWith(full URI)": (uri) => uri.startsWith(REGISTERED),
  "regex, dots unescaped": (uri) => new RegExp("^https://app.example/callback$").test(uri),
  "exact string match": (uri) => uri === REGISTERED,
};

export const candidates = [
  "https://app.example/callback",
  "https://app.example.attacker.test/callback",
  "https://app.example/callbackx",
  "https://appxexample/callback",
];
scope.mjs
// A resource server that checks what the token was issued for: scope (which actions) and audience (which service).
export function authorize(token, { audience, requiredScope }) {
  if (token.aud !== audience) return "denied: wrong audience";
  if (!token.scope.split(" ").includes(requiredScope)) return "denied: missing scope " + requiredScope;
  return "allowed";
}
demo.mjs
import { s256, newVerifier, AuthServer } from "./pkce.mjs";
import { matchers, candidates } from "./redirect.mjs";
import { authorize } from "./scope.mjs";

const attempt = (label, fn) => {
  try { console.log(`  ${label}\n    -> ok (${JSON.stringify(fn())})`); } catch (e) { console.log(`  ${label}\n    -> ${e.message}`); }
};

console.log("1. RFC 7636 appendix B test vector");
const vector = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
console.log("  expected E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
console.log("  computed " + s256(vector));
console.log("  match: " + (s256(vector) === "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"));

console.log("\n2. PKCE: a stolen authorization code");
const strict = new AuthServer({ requirePkce: true });
const verifier = newVerifier();
const code = strict.authorize({ redirectUri: "https://app.example/callback", challenge: s256(verifier), method: "S256" });
const stolen = code;
attempt("attacker with the code, no verifier", () => strict.token({ code: stolen }));
const code2 = strict.authorize({ redirectUri: "https://app.example/callback", challenge: s256(verifier), method: "S256" });
attempt("attacker guesses a verifier", () => strict.token({ code: code2, verifier: newVerifier() }));
const code3 = strict.authorize({ redirectUri: "https://app.example/callback", challenge: s256(verifier), method: "S256" });
attempt("legitimate client with its verifier", () => strict.token({ code: code3, verifier }));

console.log("\n3. PKCE not enforced");
const lax = new AuthServer({ requirePkce: false });
const open = lax.authorize({ redirectUri: "https://app.example/callback" });
attempt("code issued without a challenge, attacker has the code", () => lax.token({ code: open }));
attempt("same request to a server that requires PKCE", () => strict.authorize({ redirectUri: "https://app.example/callback" }));
const noChallenge = lax.authorize({ redirectUri: "https://app.example/callback" });
attempt("verifier sent although no challenge was registered", () => lax.token({ code: noChallenge, verifier }));

console.log("\n4. Redirect URI comparison (registered: https://app.example/callback)");
for (const [name, match] of Object.entries(matchers)) {
  console.log(`  ${name}`);
  for (const uri of candidates) console.log(`    ${match(uri) ? "accepts" : "rejects"} ${uri}`);
}

console.log("\n5. What a leaked token can do");
const narrow = { aud: "api.projects.example", scope: "projects:read" };
const broad = { aud: "api.projects.example", scope: "projects:read projects:write secrets:read" };
for (const [name, token] of [["narrow token", narrow], ["broad token", broad]]) {
  console.log(`  ${name} (${token.scope})`);
  console.log(`    read projects: ${authorize(token, { audience: "api.projects.example", requiredScope: "projects:read" })}`);
  console.log(`    read secrets:  ${authorize(token, { audience: "api.projects.example", requiredScope: "secrets:read" })}`);
  console.log(`    replay at the billing API: ${authorize(token, { audience: "api.billing.example", requiredScope: "projects:read" })}`);
}

What we did not verify

  • How the compromised OAuth app led to the employee's account in this incident, and whether any control above would have prevented it. Vercel's bulletin does not say.
  • Context.ai's own account, the press coverage, and the number of affected customers beyond what Vercel states.
  • Any real authorization server or provider. The mock implements only the checks shown, not a full OAuth server, and does not cover ID tokens, refresh tokens, or sender-constrained tokens.
  • That the platform settings Vercel recommends do what the bulletin says; we did not use them.

For how install-time code and agent tools widen what a compromised dependency or planted instruction can reach, see what npm install runs and when the data an AI agent reads can give orders.

References