---
title: "Beyond Token Revocation: How VS Code Extensions Can Plant Persistent GitHub Backdoors"
author: "Declan Osei"
category: "Red Teaming & Offensive Research"
date: 2026-08-20T16:08:43.544Z
canonical: "https://agenticcyber.co/blog/beyond-token-revocation-how-vs-code-extensions-can-plant-persistent-github-backd"
---

# Beyond Token Revocation: How VS Code Extensions Can Plant Persistent GitHub Backdoors

![Colorful JavaScript code lines displayed on a dark monitor screen.](https://images.unsplash.com/photo-1516259762381-22954d7d3ad2?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4OTQwNjJ8MHwxfHNlYXJjaHwxfHxWUyUyMENvZGUlMjBleHRlbnNpb24lMjBhdHRhY2t8ZW58MXwwfHx8MTc4NzI0MjM4M3ww&ixlib=rb-4.1.0&q=75&w=1200&auto=format)

We audited a developer environment last year where the team was convinced they had cleaned up a compromised extension. They revoked the OAuth token, uninstalled the extension, and moved on. Three months later, we found a deploy key in their repository that had been created during the original compromise window - still active, still read/write. The extension was long gone. The access wasn't.

VS Code extension supply chain attacks are more persistent than most teams assume. The OAuth token or PAT is the entry point, not the exit point. What gets planted in the gap between installation and discovery is the part that survives remediation. This piece is specifically about that gap: the persistence mechanisms that outlast token revocation, and what full incident response actually looks like when an extension has had write access to your repositories.

## Understanding VS Code Extension Persistence Mechanisms

  ![](https://images.unsplash.com/photo-1771942202908-6ce86ef73701?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4OTQwNjJ8MHwxfHNlYXJjaHwxfHxVbmRlcnN0YW5kaW5nJTIwVlMlMjBDb2RlJTIwRXh0ZW5zaW9uJTIwUGVyc2lzdGVuY2UlMjBNZWNoYW5pc21zfGVufDF8fHx8MTc4NzI0MjM4M3ww&ixlib=rb-4.1.0&q=75&w=960&auto=format)
  Photo by [Bharath Kumar](https://unsplash.com/@bharath9110) on [Unsplash](https://unsplash.com)

There are three places a malicious extension can plant credentials that survive token revocation. Each operates independently of the access token that enabled the attack in the first place.

The first is deploy keys. GitHub deploy keys are SSH key pairs scoped to a specific repository. An extension with a valid token can call POST /repos/{owner}/{repo}/keys to register a public key and retain the private key permanently. Once registered, that SSH key grants push access to the repository regardless of what happens to the token that created it. The key persists until explicitly deleted from the repository's settings.

The second is extension secret storage. VS Code exposes vscode.SecretStorage, which allows extensions to store credentials in the OS-level credential manager - on macOS, that's Keychain; on Windows, Credential Manager; on Linux, libsecret. An extension can store a token or a private key here and retrieve it across sessions without prompting the user. If you uninstall the extension without clearing its stored secrets, those credentials may persist in the OS credential manager depending on how the extension handled cleanup.

The third is workflow injection. An extension with write access can modify files in .github/workflows/ and push those changes to the repository. Once committed, the workflow runs on its own trigger - push events, pull requests, scheduled crons - independent of any extension or credential. The malicious code is now in your repository's Git history, executing on GitHub's infrastructure every time the trigger fires.

The extension lifecycle that enables all of this is fast. Activation triggers on workspace open. If the user has previously authenticated, the extension retrieves cached credentials from vscode.SecretStorage or via the GitHub authentication provider. From there, it has a valid token window to make API calls. Creating a deploy key via the REST API takes one round trip. Modifying and pushing a workflow file requires vscode.workspace.fs.writeFile followed by vscode.commands.executeCommand('git.push') - two calls that complete in seconds. By the time a user notices anything unusual, the persistent credential is already registered on GitHub's side.

The architectural distinction that matters here: OAuth tokens and PATs are ephemeral by design. They can be revoked centrally. Deploy keys and SSH keys are persistent by design - they are intended to survive session boundaries, which is why they are useful for CI/CD. A malicious extension is exploiting the legitimacy of that second category to survive the revocation of the first.

## Why Token Revocation Alone Leaves You Exposed

The false assumption is understandable: if the extension needed a token to do damage, revoking the token stops the damage. This is true if the extension only used the token for direct API calls and stored nothing. It is false if the extension used the token as a bootstrapping credential - a one-time key to plant something more durable.

Here is the attack sequence in concrete terms. The extension activates and obtains a valid GitHub token through the user's authenticated session. In the same activation window, it calls the deploy keys API to register a new SSH key pair, storing the private key in vscode.SecretStorage or exfiltrating it to an attacker-controlled endpoint. The user revokes the OAuth token a week later, after seeing it listed in a security advisory. GitHub's revocation removes the token from all active sessions. The deploy key is unaffected - it was created using the token, but it is not the token. It lives in the repository's settings under a separate credential namespace.

GitHub's token revocation system is not designed to cascade. Revoking a token does not trigger an audit of what that token created during its lifetime. There is no automated cleanup of deploy keys, webhooks, or workflow modifications made by the now-revoked token. That would require GitHub to maintain a full provenance graph of every API side effect produced by every credential - which it does not do. The revocation is scoped to the credential itself, not to the consequences of its use.

A properly designed extension avoids this entirely by using tokens only for the immediate operation: request, execute, discard. No persistent storage, no secondary credential creation. In practice this is hard to enforce because VS Code's extension architecture actively provides vscode.SecretStorage as a convenience for extensions that need to authenticate across sessions. The API exists for legitimate reasons. The problem is that it creates a persistence surface that most developers do not audit.

## Detect and Audit Deploy Key Creation

The first concrete action after suspecting a compromise: enumerate every deploy key registered on the affected repositories. GitHub's REST API makes this straightforward.

curl -H "Authorization: Bearer YOUR_TOKEN" \
  https://api.github.com/repos/{owner}/{repo}/keys

The response includes each key's ID, title, creation timestamp, and whether it has write access. What you are looking for: keys with no recognizable title, keys created at unusual times (late night, weekends, during a period when you know the extension was active), and keys with write access that you cannot attribute to known CI/CD infrastructure.

The complication is that many legitimate tools also create deploy keys - GitHub Actions runners, deployment pipelines, external CI services. You need a baseline. If you maintain an inventory of approved deploy keys (titles, creation dates, associated infrastructure), you can diff against the API response and isolate anything unrecognized. If you do not maintain that inventory, building it reactively after a suspected compromise is genuinely difficult. We have been in that situation and it is not a good place to be making forensic decisions.

Once you identify a suspicious deploy key, do not revoke it immediately if you are in an active investigation - you want to log any authentication attempts against it first. But if you are in immediate remediation mode: revoke the key, then audit every commit pushed using that key's SSH fingerprint against your repository's push event log in the GitHub audit log. Any commit pushed by that key should be treated as potentially malicious until reviewed.

## Monitor Workflow File Changes and Injections

A workflow injection looks innocuous in a diff. A malicious extension adding a step to an existing workflow might insert something like a run step that calls curl with environment variable contents posted to an external endpoint. It is easy to miss in a noisy changelog, especially if the extension committed it alongside a legitimate change.

The persistence here is categorical. Once that workflow file is committed to the repository, it executes on GitHub's infrastructure on its defined trigger. Uninstalling the extension does not revert the commit. Revoking the token does not revert the commit. The only remediation is reverting the commit itself and auditing all workflow runs since the injection was introduced.

Detection requires treating your .github/workflows/ directory as a high-integrity path. Set up a workflow that triggers on push events to that path and diffs incoming changes against a pinned baseline or a checksum registry. Tools like [yamllint](https://github.com/adrienverge/yamllint) can catch structural anomalies; a simple hash comparison against a known-good state catches unauthorized modifications. Neither approach is perfect - a sophisticated attacker can craft a workflow injection that passes structural linting - but both raise the cost of a silent compromise.

If you find an injected workflow step, the remediation sequence is: revert the commit, then pull all workflow run logs from the period between injection and detection. Look specifically for unexpected outbound network calls, base64-encoded outputs in step logs, and any steps that access secrets.* context variables. Assume any secret that was accessible to a workflow run during the compromise window is exposed.

## Implement Credential Isolation and Ephemeral Token Policies

The principle is simple: extensions should not store credentials. If an extension needs to authenticate to GitHub for a specific operation, it should obtain a token, use it, and discard it from memory. It should not write it to vscode.SecretStorage, to disk, or to any location that persists across the extension's execution context.

Enforcing this in practice means auditing what extensions actually declare in their manifests and what APIs they call. Extensions that request access to secretStorage are flagging themselves as credential-storing - that is not automatically malicious, but it warrants scrutiny. An extension that authenticates to GitHub via the device flow, makes one API call, and terminates the token is a fundamentally different trust profile than one that caches a token for session persistence.

A practical policy for teams: extensions that must authenticate to GitHub should use a separate, low-privilege GitHub account scoped to the specific repositories they need, not the developer's main account with organization-wide access. This limits the blast radius if an extension is compromised. A deploy key created against a limited account can only access the repositories that account has permission on. It does not give an attacker lateral access across your entire organization's repository surface.

We also recommend explicitly reviewing which extensions have vscode.SecretStorage entries. This is not natively surfaced in VS Code's UI, but extensions can be audited by inspecting the OS credential manager directly - on macOS, searching Keychain for entries with "vscode" in the service name will surface what is stored. It is a manual process, which is part of why most teams skip it.

## Use GitHub Deploy Key Restrictions and Commit Signing Enforcement

GitHub Enterprise provides controls on deploy key capabilities that are worth configuring even before a compromise. The most important: default all deploy keys to read-only access. A malicious extension that creates a deploy key via the API will get a read-only key, which prevents push access. This does not stop the extension from reading your repository contents - a real concern for sensitive codebases - but it blocks the commit injection vector.

Combine this with required commit signing. If your repository requires all commits to be signed with a GPG or SSH key, and you maintain a registry of approved signing keys, a commit pushed by an attacker-controlled deploy key will fail the signing check unless the attacker has also compromised a registered signing key. These are two independent credential systems, which makes simultaneous compromise harder.

The limitation to name plainly: deploy key restrictions on GitHub.com (not Enterprise) are less granular. You can set a deploy key to read-only at creation time, but there is no organization-level policy that prevents write-enabled deploy keys from being created if the creating token has sufficient permissions. On GitHub.com, the primary control is repository-level branch protection, not deploy key capability restrictions.

## Sandbox Extensions and Restrict File System Access

VS Code extensions run as Node.js processes with access to the file system. The VS Code API provides vscode.workspace.fs as a layer above raw file system access, but extensions are not prevented from using Node's fs module directly. An extension that bypasses the VS Code API and writes directly to .git/config or .github/workflows/ is operating outside the intended extension contract.

On Linux, you can monitor for this using strace against the extension host process. On macOS, Endpoint Security or tools like Process Monitor can capture file system events at the process level. These give you visibility into what an extension is actually writing, rather than what its manifest claims it needs. The limitation: this is reactive. By the time you detect the write, it has happened.

The more durable approach is environmental isolation. Run VS Code inside a container with a read-only mount for the repository, except for a designated working directory. Extensions running in that container cannot write to .git/ or .github/ directly because those paths are read-only at the mount level. This is inconvenient for developers who need extensions to modify project configuration, but for security-critical environments where the extension threat model is taken seriously, the tradeoff is worth it.

## Establish Repository Access Controls and Audit Logging

Branch protection rules are your last line of defense against a successful deploy key abuse. If a malicious extension creates a write-enabled deploy key and attempts to push to main, a branch protection rule requiring at least one approved review before merge will block a direct push. The attacker's commit goes to a branch - visible, reviewable - rather than silently landing on your default branch.

Require status checks as well. If your CI pipeline must pass before a merge is permitted, a backdoored commit that breaks tests is rejected automatically. This is not a complete defense - an attacker who understands your test suite can craft a commit that passes - but it adds friction and creates audit surface.

GitHub's [organization audit log](https://docs.github.com/en/organizations/keeping-your-organization-secure/managing-security-settings-for-your-organization/reviewing-the-audit-log-for-your-organization) records deploy key creation, push events, and API authentication events. Export this to a SIEM or log aggregation system and build alerts on specific patterns: a deploy key created and used within a short time window, push events from an SSH key that has no associated CI job name, API calls that create repository keys outside of known automation accounts. These are not high-confidence signals on their own, but they are the closest thing to real-time detection available without endpoint-level

## FAQ

### Does revoking a GitHub OAuth token remove deploy keys created by a compromised VS Code extension?

No. Revoking an OAuth token or PAT only removes that specific credential - it does not cascade to delete deploy keys, webhooks, or workflow changes that were created using the token while it was active. GitHub does not maintain a provenance graph of API side effects tied to each credential, so a deploy key planted by a malicious extension remains active in your repository settings until you manually delete it, even after the original token is revoked.

### How can I check whether a malicious VS Code extension created a deploy key on my GitHub repository?

Use the GitHub REST API to enumerate all registered deploy keys: run 'curl -H "Authorization: Bearer YOUR_TOKEN" https://api.github.com/repos/{owner}/{repo}/keys'. The response shows each key's ID, title, creation timestamp, and whether it has write access. Look for keys with unrecognizable titles, write access you cannot attribute to known CI/CD infrastructure, or creation timestamps that fall within the window when the suspicious extension was active. If you maintain an inventory of approved deploy keys, diff the API response against that baseline to isolate anything unrecognized.

### Can a VS Code extension persist access even after you uninstall it?

Yes, in two ways. First, if the extension stored credentials in VS Code's SecretStorage - which writes to the OS-level credential manager (Keychain on macOS, Credential Manager on Windows, libsecret on Linux) - those entries may remain after uninstall depending on how the extension handled cleanup. Second, if the extension injected code into a file under .github/workflows/ and pushed that commit, the workflow continues to execute on GitHub's infrastructure on its defined trigger regardless of whether the extension is installed. Uninstalling the extension does not revert committed changes.

### What does a workflow injection from a malicious VS Code extension look like, and how do you detect it?

A workflow injection typically adds a step to an existing workflow file - for example, a 'run' step using curl to post environment variable contents to an external endpoint. It can be easy to miss in a noisy changelog, especially if committed alongside a legitimate change. To detect it, treat your .github/workflows/ directory as a high-integrity path: set up a workflow that triggers on push events to that path and diffs incoming changes against a pinned baseline or checksum registry. Tools like yamllint can catch structural anomalies. If you find an injected step, revert the commit and review all workflow run logs from the injection window for unexpected outbound network calls, base64-encoded outputs, or steps accessing secrets context variables.

### What practical steps can a team take to limit the damage if a VS Code extension is compromised?

Several controls reduce blast radius. First, use a separate low-privilege GitHub account scoped only to the repositories an extension needs, rather than a developer's main account with organization-wide access - this limits what a planted deploy key can reach. Second, on GitHub Enterprise, set deploy keys to read-only by default so a malicious extension cannot push commits even if it registers a key. Third, enforce required commit signing so commits from an unrecognized deploy key fail the signing check. Fourth, apply branch protection rules requiring at least one approved review before merging to your default branch, which blocks a direct push from an attacker-controlled key. Finally, export your organization audit log to a SIEM and alert on patterns like a deploy key being created and used within a short time window or push events from an SSH key not associated with a known CI job.


---
Source: https://agenticcyber.co/blog/beyond-token-revocation-how-vs-code-extensions-can-plant-persistent-github-backd