Skip to content
Published on

Where Developer Tokens Leak — The VSCode 1-Click Token Theft and Secret Hygiene

Authors

Introduction — A Token Vanished in a Single Click

In June 2026, a security analysis post became a hot topic on Hacker News. It described how a single VSCode bug could be abused to steal a GitHub token with nothing more than the victim clicking a link. The analysis, published by security researcher Ammar Askar, reawakened an uncomfortable truth: your IDE can betray you.

The crux is that the boundaries between the IDE, the browser, and the OAuth flow are flimsier than we think. We tend to believe tokens are safe as long as we hide them well, but tokens actually leak through countless paths we never notice: plaintext config files, environment variables, shell history, and, as in this incident, even an IDE URL handler.

This post has two parts. First I dissect the attack chain of the VSCode token theft at a conceptual level, then I lay out secret-hygiene strategies developers can apply day to day, centered on configuration examples. The conclusion up front: rather than hiding tokens well, it is far more powerful to reduce their permissions, shorten their lifetime, and eliminate them entirely.

The Attack Chain of the VSCode 1-Click Token Theft

Instead of concrete exploit code, I will explain at a conceptual level which structural flaws combined to make the incident possible. To understand the defense, you have to understand the shape of the attack.

[1] Victim clicks a malicious link (email, chat, web page)
        |
        v
[2] A custom URL scheme (vscode://) brings the IDE to the foreground
        |
        v
[3] An IDE extension or built-in handler does not sufficiently validate URL parameters
        |
        v
[4] The OAuth callback/auth flow is steered toward an attacker-controlled redirect
        |
        v
[5] The GitHub auth token is delivered to the attacker endpoint
        |
        v
[6] The attacker uses the token to access the victim repositories

Three structural weaknesses in this chain deserve attention.

First, custom URL schemes are powerful but dangerous. A scheme like vscode:// lets the browser invoke the IDE directly through the OS. If the IDE trusts the parameters passed in, an external party can steer the IDE internal behavior.

Second, there is a gap in OAuth redirect validation. OAuth returns a token to a specific redirect URI after authentication, and if that URI validation is loose (substring matching, wildcard allowance, and so on), an attacker can divert the token to their own endpoint.

Third, minimizing user interaction is paradoxically risky. Skipping confirmation steps for the sake of "one-click" convenience robs the victim of the chance to realize what they just approved.

The lesson of this incident is clear: any token, once exposed, must be treated as compromised immediately, so we should issue and operate tokens whose exposure causes little harm.

Where Do Tokens Live in a Developer Environment

First, let us face reality. Here are the locations where tokens are stored on an ordinary developer machine.

Storage locationSecurity levelRisk
Plaintext config files (.npmrc, .git-credentials)Very lowLeaks via disk access, backups, sync
Environment variables (.bashrc, .zshrc export)LowPropagated to child processes, exposed in logs
.env filesLowAccidentally committed, baked into Docker images
OS keychain (macOS Keychain, etc.)HighDepends on app permission model
Secret managers (Vault, cloud KMS)Very highOperational complexity

Most leakage incidents originate from the top three rows. The most common of all is the habit of exporting tokens into environment variables.

# Anti-pattern — never do this
export GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx
export AWS_SECRET_ACCESS_KEY=xxxxxxxxxxxx

The problem with this approach is that the token is automatically inherited by every child process. A script you ran absent-mindedly, a package install script, even a compromised CLI tool can read this token. This is exactly why environment variables are a prime exfiltration target in the npm supply chain attacks discussed earlier.

The basic principle: inject tokens only into the process that needs them, only when needed, and delegate to the OS keychain or a secret manager whenever possible.

PAT Permission Design — Classic vs Fine-grained

GitHub Personal Access Tokens come in two kinds. Understanding the difference between them is the starting point of token security.

AspectClassic PATFine-grained PAT
Permission scopeBy scope (repo, workflow, etc.)Per-repo plus granular permissions
Target reposAll repos in the accountOnly selected repos
ExpiryConfigurable (no-expiry allowed)Max 1 year, expiry required
Org approvalLimitedControllable by org policy
RecommendationDiscouraged (legacy)Recommended

The repo scope of a classic PAT effectively grants read/write to every repository in the account. A single leaked token exposes all repos. A fine-grained PAT, by contrast, lets you specify "only this permission, on this repo."

The principles of permission design are simple.

1. Least privilege: grant only the minimum permissions the task needs
2. Least scope: explicitly select the target repos
3. Short lifetime: the shortest feasible expiry (e.g., 7 days, 30 days)
4. Single purpose: one token for one use
5. Traceable: name the token so its purpose is obvious

For example, a token that pushes releases to a specific repo in CI needs only contents:write and that one repo. It needs no permission for issues or other repos at all.

Token Lifetime and Rotation

The most underrated aspect of token security is lifetime. A token with no expiry is a time bomb. A token that stays valid for years with no idea of when it leaked is a recurring cause of incidents.

A rotation strategy looks like this.

- Personal PAT: expiry within 90 days + quarterly rotation
- CI/automation tokens: within 30 days, or replaced by OIDC (below)
- Service account tokens: automatic rotation via a secret manager
- On suspected leak: immediate revocation + new token, no exceptions

If you do not automate rotation, eventually no one does it. With the GitHub CLI you can keep a script that periodically checks token status.

# Check current auth status and token scopes
gh auth status

# Delegate the token to secure storage like the keychain (avoid plaintext files)
gh auth login --hostname github.com --git-protocol https

GitHub Secret Scanning and Push Protection

GitHub automatically detects known forms of secrets in code pushed to repositories. The key feature is push protection. If a secret is included in a commit, the push itself is rejected, blocking the leak before it happens.

Beyond enabling it at the org/repo level, to get the same protection locally you should run a pre-blocking tool alongside it. Here is a conceptual diagram of how GitHub push protection works.

git push
   |
   v
[GitHub server] -- detect secret patterns in the pushed diff
   |
   +-- secret found --> reject push, point the developer to the location
   |
   +-- clean --> push succeeds

GitHub also partially supports the already-leaked case. When a token format registered in the partner program (such as a cloud key) is found in a public repo, GitHub notifies the service provider to trigger automatic revocation. But this is a last-resort safety net, not a first line of defense. Secrets should not be committed in the first place.

Triple Defense Against .env Leaks

The .env file is the number-one path for developer secret leaks. Block it three ways.

Step 1 — gitignore

The most basic, yet the most frequently omitted.

# .gitignore
.env
.env.*
!.env.example
*.pem
*.key
.git-credentials

The negation pattern on the second-to-last relevant line matters. Commit .env.example so the team knows which variables are needed, while blocking every file that holds real values.

Step 2 — Pre-blocking with a pre-commit hook

Scan for and block secrets at commit time. gitleaks is the de facto standard.

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.0
    hooks:
      - id: gitleaks

After installing, register the hook with the following.

pip install pre-commit
pre-commit install
# Now gitleaks runs automatically on every commit

Step 3 — gitleaks custom configuration

Beyond the default ruleset, you can add internal token formats.

# .gitleaks.toml
title = "Internal gitleaks config"

[extend]
useDefault = true

[[rules]]
id = "internal-api-key"
description = "Internal API key pattern"
regex = '''myco_(live|test)_[0-9a-zA-Z]{32}'''
tags = ["key", "internal"]

[allowlist]
description = "Allow test fixtures"
paths = [
  '''test/fixtures/.*''',
]

Double-check in CI by scanning the full history as well.

# .github/workflows/gitleaks.yml
name: Secret Scan
on: [pull_request]

permissions:
  contents: read

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: gitleaks/gitleaks-action@v2
        env:
          GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}

Safe git-credential Helper Configuration

How git stores credentials depends on the credential.helper setting. The most dangerous is the store helper.

# Dangerous — stores credentials in plaintext at ~/.git-credentials
git config --global credential.helper store

This setting writes the token to your home directory in plaintext. It leaks straight out via backups, cloud sync, or disk access by another process. Use the OS-appropriate secure helper instead.

# macOS — delegate to Keychain
git config --global credential.helper osxkeychain

# Windows — Credential Manager
git config --global credential.helper manager

# Linux — libsecret (GNOME Keyring, etc.)
git config --global credential.helper libsecret

# Or cache only (briefly in memory, no disk write)
git config --global credential.helper 'cache --timeout=3600'

Additionally, separating credentials by host means that if one host is compromised, others remain safe.

# ~/.gitconfig
[credential "https://github.com"]
    helper = osxkeychain
[credential "https://gitlab.internal.example.com"]
    helper = osxkeychain

IDE Extension Supply Chain Risk

At its core, the VSCode incident is also a trust-model problem in the extension ecosystem. Extensions run with the full permissions of the IDE: file system read/write, network communication, shell command execution, and even access to secrets stored by other extensions.

Here is the reality of the extension permission model.

Capabilities of an installed VSCode extension:
  - Read every file in the workspace (including .env)
  - Send arbitrary network requests
  - Execute commands in the integrated terminal
  - Access tokens stored via the SecretStorage API (cross-extension isolation exists but is not perfect)
  - Auto-update — an extension that was safe yesterday can be malicious today

Practical guidelines:

[ ] Install only extensions from verified publishers
[ ] Check download counts, update frequency, and whether it is open source
[ ] Enable Workspace Trust — restrict extensions in untrusted folders
[ ] Disable/remove extensions you do not use
[ ] Work on sensitive projects in a separate profile or separate machine
[ ] Turn off extension auto-update and review changes (high-sensitivity environments)

Workspace Trust is especially important. When you open an unfamiliar cloned repository, it prevents that folder settings from automatically executing code.

CI Secrets — Eliminating Long-Lived Tokens with OIDC

If everything so far was about handling tokens safely, now let us look at eliminating them altogether. When deploying to the cloud from CI, the traditional approach stored a long-lived access key in CI secrets. If that key leaks, it is game over.

OIDC federation (OpenID Connect federation) solves this problem fundamentally. On each CI run, the cloud provider issues short-lived credentials that expire when the job finishes. No stored long-lived secret exists.

[Traditional approach — long-lived key]
  Store an AWS access key in CI secrets --> permanent compromise if leaked

[OIDC approach — short-lived token]
  GitHub Actions issues an OIDC token
        |
        v
  The cloud verifies the token issuer/repo/branch
        |
        v
  On success, issues short-lived credentials (expire in tens of minutes)
        |
        v
  Credentials vanish when the job ends

Here is an example of connecting GitHub Actions to AWS via OIDC.

# .github/workflows/deploy.yml
name: Deploy
on:
  push:
    branches: [main]

permissions:
  id-token: write   # required for OIDC token issuance
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-deploy
          aws-region: ap-northeast-2
          # No access key — assume the role directly via OIDC
      - run: aws s3 sync ./dist s3://my-bucket/

On the cloud side, a trust policy restricts which repo/branch may assume the role.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:myorg/myrepo:ref:refs/heads/main"
        }
      }
    }
  ]
}

The sub condition here is the key defense. Only a workflow running on a specific branch of a specific repo may assume this role. A forked PR or another repo cannot obtain credentials. Setting the condition too loosely (for example, a wildcard over all branches) defeats the purpose, so take care.

GCP and Azure offer the same pattern (Workload Identity Federation, federated credentials). For a new project, it is right to check whether OIDC is possible before issuing a long-lived key.

Incident Response Runbook

Here is the procedure when a token leak is suspected. You do not need to memorize it, but it should be written down somewhere.

[Immediately] Revoke
  - Revoke the leaked/suspected token at once (GitHub Settings, cloud console)
  - Revocation comes before issuing a new token — minimize the exposure window

[5 min] Assess scope
  - Confirm the token permission scope (which repos, what actions were possible)
  - Query the GitHub audit log / cloud CloudTrail for abnormal activity
  - List the actions the token enabled (push, package publish, resource creation)

[30 min] Contain and inspect
  - Replace the token on every system that used the same one
  - Identify and review suspicious commits/releases/deployments
  - Check whether new SSH keys or deploy keys were added (backdoors)

[After] Prevention
  - Remove plaintext storage paths, migrate to keychain/secret manager
  - Replace tokens with OIDC where possible
  - Verify gitleaks pre-commit + CI scanning are in place
  - If a secret remains in git history, consider rewriting history

The last item needs care. Once a secret enters commit history, adding a new commit that deletes the file leaves it in the past commits. History rewriting (git filter-repo, etc.) is required, and above all, a leaked token must be revoked regardless of whether you rewrite history.

Checklist

For individual developers:

[ ] Do not export long-lived tokens into environment variables
[ ] Set git credential.helper to an OS keychain, not store
[ ] Use fine-grained PATs with least privilege + expiry
[ ] Include .env, *.pem, *.key, .git-credentials in .gitignore
[ ] Install the gitleaks pre-commit hook
[ ] Enable VSCode Workspace Trust, minimize extensions
[ ] Review and rotate tokens quarterly

For organizations:

[ ] Enable secret scanning + push protection across all repos
[ ] Replace CI long-lived keys with OIDC federation
[ ] Fine-grained PAT policy + restrict classic PATs
[ ] Register gitleaks as a required PR check
[ ] Document the token leak incident response runbook
[ ] Include secret hygiene training in developer onboarding

Pitfalls and Counterarguments

First, the keychain is not a silver bullet either. The keychain protects only while the OS is locked. If malicious code runs while the machine is unlocked, it can read the keychain with normal app permissions. The keychain is far better than plaintext files, but it has limits once the machine itself is compromised.

Second, OIDC is no silver bullet either. If you set the sub condition loosely (branch wildcard, environment unspecified), a forked PR or an unintended workflow can obtain credentials. OIDC safety depends entirely on the strictness of the trust policy. What matters is not that you adopted it but whether you narrowed the conditions precisely.

Third, gitleaks is pattern-based. It catches known secret formats well, but it can miss irregular internal secrets or values obfuscated via base64. Even with custom rules it is not perfect, so detection tools are a last line of defense, not absolution.

Fourth, the tension between convenience and security is eternal. Shortening token lifetime increases rotation burden; narrowing permissions blocks work. A policy that ignores this tension breeds workarounds. Lowering the cost of security through automation (auto-rotation, OIDC, secret manager integration) is the only sustainable path.

Fifth, as this VSCode incident shows, the very tools we trust are an attack surface. IDEs, extensions, CLIs, and package managers all touch tokens. The fundamental direction is to reduce the number of tools that handle tokens and to reduce each tool permissions.

Closing Thoughts

The VSCode 1-click token theft shows that the question "where should I hide the token" is wrong. The right question is "how do I design this token so that its leak causes little harm."

Summarized in three points:

  1. Reduce permissions: fine-grained PATs, least scope, single purpose.
  2. Reduce lifetime: short expiry, regular rotation, immediate revocation on leak.
  3. Eliminate tokens: OIDC for CI, keychain/secret manager for storage.

All three directions point to one place. Since you cannot perfectly prevent a token from being exposed, you structurally minimize the harm when it is. That is secret hygiene in the 1-click era.

References