Security model¶
gh-claude exists to hand Claude Code a credential you can afford to lose. Everything in its design follows from that: the token is minted with the least privilege that still lets Claude do useful work, it dies young, it is stored in the strongest secret store the machine has, it is wired into the child process only — and the binary that does all this can prove where it came from and can be remotely revoked if a release ever turns out to be bad.
At a glance:
| Claude gets | Claude never sees |
|---|---|
a scoped token via GITHUB_TOKEN |
your OS keychain |
contents: read (source code, no push) |
your real gh credential |
issues: read/write |
git push (rejected) |
pull requests: read/write |
anything past 7 days |
The token¶
Scope¶
The extension provisions a fine-grained personal access token
(github_pat_…) with exactly these permissions, applied to all repositories
you can access:
| Permission | Access | Why |
|---|---|---|
contents |
read | clone/fetch source; git push is rejected |
issues |
read and write | work the backlog |
pull_requests |
read and write | open and review PRs |
metadata |
read | added automatically by GitHub |
Classic tokens are refused outright: the paste step checks the github_pat_
prefix, because only fine-grained PATs can carry this per-permission shape and
the 7-day expiry.
Why a browser step at all¶
GitHub has no API to mint a fine-grained PAT — they can only be created in
the browser. The fully-automated alternative, a GitHub App, would mean shipping
an app private key to every developer machine, which this project explicitly
avoids. So gh-claude automates everything except the creation click: it opens
https://github.com/settings/personal-access-tokens/new pre-filled with the
right name, description, 7-day expiry, and permissions; you choose "All
repositories" and click Generate; the extension does the rest. The browser
comes round roughly once a week.
The pre-filled name is gh-claude (<host> <DDMMYYYY>) — the machine's short
hostname plus the creation date, e.g. gh-claude (mac 02072026). Fine-grained
PAT names are unique per user, and a renewal usually happens while the
previous token is still live, so the date stamp is what keeps the new token's
name from colliding with the one it replaces.
Lifecycle¶
- Validated on paste. The extension calls GitHub with the new token and
reads the exact expiry from the
GitHub-Authentication-Token-Expirationresponse header. A token that lives longer than 7 days (plus a 12-hour clock-skew tolerance) is rejected — a long-lived paste cannot sneak in. - Reused until 5 minutes before expiry. Within that buffer, or when GitHub actively rejects the token (revoked), a fresh one is provisioned. Within 24 hours of expiry you are warned that the browser step is coming.
- Trusted when offline. If GitHub simply cannot be reached to re-validate, the stored token is used anyway — transient network failure does not lock you out.
Where the token lives¶
The strongest store available wins; gh claude status shows which backend is in
use.
macOS Keychain¶
Stored as a generic password (service gh-claude) in the login Keychain via the
built-in /usr/bin/security tool — standard library only, no cgo, no
third-party keyring, so it works identically in the release binaries. The
backend is probed at startup; if the keychain is unreachable the encrypted-file
fallback is used (with a warning, since on macOS that is unexpected).
Windows Credential Manager¶
A generic credential named gh-claude:github.com, written through advapi32
(CredWriteW/CredReadW/CredDeleteW) — again pure standard library, no cgo.
Encrypted file (Linux, WSL2, everything else)¶
Hosts without a native keychain get an encrypted file under the gh-claude config
directory ($XDG_CONFIG_HOME/gh-claude or ~/.config/gh-claude) — this is the
standard backend there, not an error condition:
- AES-256-GCM, per-item random salt and nonce, with the format version and key slot bound into the authenticated data.
- The key is derived with HKDF-SHA256 from a stable machine identifier
(
/etc/machine-idor/var/lib/dbus/machine-id), or from a generated per-install key (mode0600) where none exists. - The directory is
0700, the files0600.
Defense in depth, not hardware
The machine id is not itself a secret, so the encryption is defense-in-depth against casual file exposure (backups, copied home directories) — the file permissions are the primary protection. If you want a real secret store on such hosts, use the 1Password backend below.
A blob that no longer decrypts is deleted and you are asked to re-authenticate
with gh claude login — the store fails safe, never half-open.
1Password (opt-in)¶
With --op (or GH_CLAUDE_STORE=1password), the token is kept as a single
concealed field of an API Credential item named gh-claude:github.com, via
the 1Password CLI. The vault comes
from --vault, then GH_CLAUDE_OP_VAULT, then the default Private;
GH_CLAUDE_OP_ACCOUNT selects an account in multi-account setups. The value
only ever crosses to op over stdin — never on a command line — and reads
prompt for 1Password's own approval (biometric/system unlock). If 1Password is
requested but op is missing or not signed in, gh-claude fails with a clear
error rather than silently falling back to a weaker store.
How Claude is launched¶
Launch wiring is process-local and ephemeral — no file on disk is modified,
your global git config and gh keychain entry are untouched:
GH_TOKENandGITHUB_TOKENare set to the scoped token in the child environment.ghprefers these over its keychain, so Claude'sghnever reads your real credential.- A per-process git credential helper is injected through git's
GIT_CONFIG_*environment mechanism: any inherited helper list is reset, then!gh auth git-credentialis installed forhttps://github.com. HTTPS clone and fetch work on private repos;git pushis rejected by the token's read-only contents scope. - On macOS and Linux the process is replaced by
claude(execve), so signals, the terminal, and the exit code pass straight through. Windows has no exec replacement, so a child process is spawned with inherited stdio and its exit code is propagated.
Environment variables are readable by your own processes
As with any GITHUB_TOKEN, the value is visible to processes running as
you (e.g. /proc/<pid>/environ on Linux). That is inherent to env-var
delivery — the read-only scope and 7-day expiry are what bound the risk.
Binary integrity¶
gh-claude ships two independent integrity mechanisms with different trust roots and cadences, on purpose:
| Build provenance | Security policy | |
|---|---|---|
| Question it answers | "Did our CI build this exact binary?" | "Is this version known-bad right now?" |
| Trust root | GitHub Sigstore attestation store | Embedded OpenSSH policy keys — Ed25519, primary + backup |
| When it runs | On demand (gh claude verify) |
On launch (gh claude, gh claude login), throttled |
| Needs network | Yes | Only to refresh a stale cache |
| Failure mode | Reports the error | Fail-open (warn) unless a trusted policy says blocked |
Why two roots? A Sigstore-keyless signature cannot be revoked — Fulcio certificates are short-lived and Rekor inclusion proofs are permanent, so a signature that verified yesterday verifies forever. Revocation therefore has to be additive: a separately-signed policy that names bad versions and that the client consults. Keeping the policy key independent of the release-signing identity means a revocation can ship without cutting a new binary release, and the policy stays trustworthy even if the release path is compromised.
Build provenance — gh claude verify¶
Verifies the running binary against GitHub's attestation store using
gh attestation verify (the gh CLI is always present — this is a gh extension
— and carries Sigstore's trusted root):
$ gh claude verify # asserts: built by our CI from this repo
$ gh claude verify --signer-workflow \
bitwise-media-group/github-workflows/.github/workflows/release.yaml
$ gh claude verify --json # machine-readable gh output
No setup needed — the release workflow already emits the SLSA build-provenance attestation. The same check works on a downloaded binary before you install it; see verifying the artifacts.
The security policy — a revocable kill switch¶
If a released version is found to leak the token, or a tampered artifact is seen in the wild, the fix must reach users faster than a release can. That is the policy system's job: a small signed JSON document, published alongside this site, that the client checks on launch and that can refuse to launch a known-bad build.
The policy document¶
The policy is served at https://oss.bitwisemedia.uk/gh-claude/policy.json
(override with GH_CLAUDE_POLICY_URL for self-hosting or testing), with a
detached OpenSSH signature at the same URL plus .sig:
{
"schema": 1,
"sequence": 7,
"issued_at": "2026-07-01T00:00:00Z",
"expires_at": "2026-07-15T00:00:00Z",
"min_safe_version": "1.4.0",
"revoked": [
{
"version": "1.3.0",
"reason": "CVE-2026-1234: scoped token written to a world-readable log",
"advisory": "https://github.com/bitwise-media-group/gh-claude/security/advisories/GHSA-xxxx-xxxx-xxxx"
},
{ "digest": "sha256:2f0c…", "reason": "tampered artifact seen in the wild" }
]
}
Field by field:
schema— the document format version; the client rejects unknown schemas rather than guessing.sequence— strictly monotonic. A fetched policy with a lower sequence than the cached one is rejected as a rollback: an attacker cannot "un-revoke" a version by replaying an older, pre-revocation policy they captured earlier.issued_at/expires_at— the freshness window. Pastexpires_at, a cached "all clear" is no longer trusted until a newer policy is fetched — this defeats a freeze attack, where an attacker blocks the client's view of new policies and serves it a stale clean one forever. Expiry does not discard a block: a known-bad verdict stands even when stale or offline.min_safe_version— a version floor. Every release below it is blocked (semver comparison, leadingvoptional), so one field revokes a whole range of vulnerable versions at once.revoked[]— precise kills: an exactversion, and/or an artifactdigest(sha256:<hex>of the running binary — this catches a tampered build whose version string is innocent). Each entry carries a humanreasonand anadvisoryURL, both shown to the user in the block message.
How it is fetched and verified¶
On launch (gh claude and gh claude login) the client:
- Loads the cached policy from
security-policy.jsonunder the gh-claude config dir — and re-verifies its signature; a tampered cache is treated as absent. - Refreshes over the network only when needed: no cache, cache older than
6 hours, or cache past its
expires_at. Fetches are bounded — a 3-second timeout per request and a 1 MiB response ceiling — so the check can never hang a launch. - Verifies the signature before believing anything. Policies are signed in
the OpenSSH signature format (
ssh-keygen -Y sign, the-----BEGIN SSH SIGNATURE-----"sshsig" blob) under the fixed namespacegh-claude-policy. The client accepts a signature made by either of two Ed25519 public keys embedded in the binary — a primary and a break-glass backup, held apart, so a lost or compromised primary never locks the channel. The keys can be FIDO2-residentsk-ssh-ed25519keys on YubiKeys: the signature then carries the authenticator's flags-and-counter envelope, which the client verifies too. Nothing secret ships in the binary — public keys only. - Enforces monotonicity: a fetched policy whose
sequenceis lower than the cached one is discarded, and the cache is kept. - Evaluates the running build: digest match, then exact version match, then
the
min_safe_versionfloor. The binary is only hashed when the policy actually contains digest rules.
What happens on each outcome¶
| Situation | Verdict | Launch |
|---|---|---|
| Trusted policy, version fine | clear | proceeds silently |
| Trusted policy revokes version/digest or below floor | blocked | refused (fail-closed) with advisory + upgrade hint |
| No policy reachable, none cached | unknown | proceeds with a stderr warning (fail-open) |
| Cached "all clear" expired, refresh failed | unknown | proceeds with a staleness warning |
| Cached policy blocks, network down | blocked | refused — offline never un-revokes |
The asymmetry is deliberate. The channel fails open on absence — a broken
CDN or an air-gapped machine must not brick every installation — but fails
closed on knowledge: once any trusted policy has said "this version is bad",
that verdict is cached and going offline does not lift it. A blocked launch
prints the reason and advisory from the policy and points at the fix:
$ gh claude
error: gh-claude 1.3.0 is blocked by the security policy: CVE-2026-1234: …
Advisory: https://github.com/bitwise-media-group/gh-claude/security/advisories/GHSA-xxxx-xxxx-xxxx
Upgrade with: gh extension upgrade claude
Escape hatches and edges¶
| Toggle / condition | Effect |
|---|---|
GH_CLAUDE_SKIP_INTEGRITY |
Any non-empty value disables the launch-time check (air-gapped use). |
GH_CLAUDE_POLICY_URL |
Override the policy endpoint (self-hosting / testing). |
| Local / snapshot builds | Report a non-release version and are never gated. |
| Unconfigured builds | With no embedded key and no URL the check is a silent no-op. |
How policies are authored, signed with a YubiKey, published, and rotated — including the disclosure runbook — is covered in Policy operations.