GitHub authentication for Go, exposed as standard oauth2.TokenSource implementations: GitHub App JWTs, installation tokens, and personal access tokens. Depends only on golang-jwt/jwt and golang.org/x/oauth2 β no GitHub SDK required.
go get github.com/jferrl/go-githubauthRequires Go 1.26+.
Authenticating as a GitHub App is a two-step chain: an RS256 JWT identifies the App, and it is exchanged for an installation token scoped to one installation. Both sources cache their tokens and refresh them proactively.
privateKey := []byte(os.Getenv("GITHUB_APP_PRIVATE_KEY"))
clientID := os.Getenv("GITHUB_APP_CLIENT_ID") // e.g. "Iv1.1234567890abcdef"
installationID, _ := strconv.ParseInt(os.Getenv("GITHUB_INSTALLATION_ID"), 10, 64)
appTokenSource, err := githubauth.NewApplicationTokenSource(clientID, privateKey)
if err != nil {
log.Fatal(err)
}
installationTokenSource := githubauth.NewInstallationTokenSource(installationID, appTokenSource)
// Every request carries a valid installation token; refresh is automatic.
// Works standalone or with any SDK that accepts an *http.Client, e.g.
// github.NewClient(httpClient) from google/go-github.
httpClient := oauth2.NewClient(context.Background(), installationTokenSource)NewApplicationTokenSource accepts a string Client ID (recommended by GitHub) or an int64 App ID (legacy) β the type is inferred from the argument. Runnable examples for every constructor live on pkg.go.dev.
oauth2.TokenSourceimplementations for GitHub App JWTs, installation tokens, and personal access tokens (classic and fine-grained)- Token caching with proactive refresh: tokens regenerate 30s before expiry, eliminating in-flight 401s (tunable via
WithExpirySkew/WithInstallationExpirySkew) - JWT signing through the standard
crypto.Signerinterface, so the private key can live in AWS KMS, GCP KMS, Azure Key Vault, Vault Transit, a PKCS#11 HSM, or ssh-agent - Webhook delivery verification (
X-Hub-Signature-256, constant-time) with ready-madehttp.Handlermiddleware - GitHub Enterprise Server and GitHub Enterprise Cloud (data residency) support
- Automatic single retry on throttled responses (
WithRetryOnThrottle, enabled by default) - Typed errors to branch on:
RateLimitError(withRetryAfter) for a throttled request,APIError(withStatusCode) for every other rejection - A
githubauthCLI with a documented exit code per failure class and--exec, which passes the credential to a command without printing it - Two dependencies total:
golang-jwt/jwtandgolang.org/x/oauth2
| Project | |
|---|---|
| Kargo | Application lifecycle orchestration |
| Terraform GitHub provider | The Terraform provider built and run by GitHub |
| gno | Go virtual machine and blockchain behind gno.land |
| Updatecli | Declarative update policy engine |
| Sippy | Dashboards for OpenShift CI test and job data |
Full list on pkg.go.dev.
The same credentials, without writing Go:
brew install jferrl/tap/githubauthOr download a binary from the latest release β Linux, macOS and Windows, on amd64 and arm64 β or build it yourself:
go install github.com/jferrl/go-githubauth/cmd/githubauth@latestFrom v1.12.0 it is also a GitHub CLI extension, run as
gh githubauth:
gh extension install jferrl/gh-githubauthgithubauth token --client-id Iv1.abc --key app.pem --installation 12345The token goes to stdout and nothing else does, so it composes:
curl -H "Authorization: Bearer $(githubauth token)" \
https://api.github.com/installation/repositoriesEvery flag falls back to an environment variable β GITHUB_APP_CLIENT_ID,
GITHUB_APP_PRIVATE_KEY, GITHUB_APP_INSTALLATION_ID β so a configured CI step is just
githubauth token. --key takes a file path, the PEM itself, or - to read stdin, which
keeps the key off disk. Pass the PEM itself through $GITHUB_APP_PRIVATE_KEY or stdin
rather than on the command line, where other local users can read it in the process list:
vault kv get -field=pem secret/github-app |
githubauth token --key - --installation 12345githubauth jwt prints the App JWT for the few endpoints that need one, and --repos and
--permissions scope the token to named repositories and to less than the App was granted.
--json adds the expiry and, for an installation token, the permissions and repositories
GitHub granted, so you can check the scope you got:
githubauth token --installation 12345 --repos api,web --permissions contents:readIf you know the account but not the installation ID, --owner looks it up, and so does an
owner/repo name in --repos:
githubauth token --owner acme --repos api
githubauth token --repos acme/api # the sameWith none of those, run inside a clone and the token is for that repository alone, found
from its origin remote, the way actions/create-github-app-token defaults to the
current repository:
cd api && githubauth token --exec -- gh pr merge 7Run githubauth help for the rest.
A printed credential stays valid for an hour, in every place your output landed: a CI log,
a terminal scrollback, a coding agent's transcript. --exec runs a command with the token
in its $GITHUB_TOKEN and $GH_TOKEN and prints it nowhere, exiting with whatever the
command exited with. When the command exits, an installation token is revoked, so a copy
it left behind stops working. Pass --keep if something it started still needs the token:
githubauth token --installation 12345 --exec -- gh pr listgit pushes as the App too, once it takes credentials from gh, which reads $GH_TOKEN
first:
gh auth setup-git # once
githubauth token --installation 12345 --exec -- git pushWith --enterprise-url or --base-url, --exec also sets $GH_HOST, port included, so
gh talks to that instance instead of github.com. A $GH_HOST you exported that names
another instance is refused before an installation token is minted, since gh would send
it there. If a proxy really serves it, set it inside the command:
--exec -- env GH_HOST=github.com gh β¦. The command never sees $GITHUB_APP_PRIVATE_KEY.
The JWT needs one more step. gh sends its credential as token, and GitHub accepts a JWT
only as Bearer, so pass the header yourself:
githubauth jwt --exec -- sh -c 'gh api -H "Authorization: Bearer $GITHUB_TOKEN" /app/installations'Scripts and agents branch on the code rather than on the message:
| Code | Meaning |
|---|---|
| 0 | a credential was printed |
| 1 | something else failed, retrying may help |
| 2 | the invocation is wrong |
| 3 | GitHub refused the key or the App's permissions |
| 4 | rate limited, wait and repeat |
| 5 | the App is not installed where it was asked to be |
Under --json, or when a coding agent is detected, a failure is one JSON document on
stdout β {"type":"githubauth.error","schema_version":"1","error":{...}}, carrying
exit_code, status_code, retry_after_seconds and suggestions β and stderr stays empty.
Detection reads the usual agent variables (CLAUDECODE, CURSOR_AGENT, and friends);
GITHUBAUTH_AGENT_MODE or --agent/--agent=false overrides it, and a test suite that
shells out to the CLI should set GITHUBAUTH_AGENT_MODE=0. The success output never
changes: a bare token stays a bare token, so $(githubauth token) means the same thing
everywhere. AGENTS.md has the full contract.
ghinstallation is the long-standing library in this space and works well. The core difference is the integration model: ghinstallation is an http.RoundTripper you install as an HTTP transport, while go-githubauth implements oauth2.TokenSource, so credentials compose with anything that speaks oauth2 β oauth2.NewClient, go-github, gRPC per-RPC credentials, or code that just needs the token string.
| go-githubauth | ghinstallation | |
|---|---|---|
| Integration model | oauth2.TokenSource |
http.RoundTripper |
| Dependencies | golang-jwt/jwt, x/oauth2 |
golang-jwt/jwt, google/go-github |
| App identifiers | Client ID (string, recommended by GitHub) and App ID (int64) |
App ID (int64) |
| Token refresh | Proactive, tunable (WithExpirySkew, default 30s) |
Proactive, fixed 1 minute |
| External signers (KMS/HSM) | Standard crypto.Signer β existing KMS adapters plug in directly |
Library-specific Signer interface |
| Webhook signature verification | Included (webhook subpackage) |
Not included |
| Personal access tokens | Included | Not included |
| GitHub Enterprise | WithEnterpriseURL (GHES) and WithBaseURL (GHEC data residency) |
BaseURL field |
If ghinstallation already fits your setup, there is no urgent reason to switch. Choose go-githubauth when you want oauth2-native composition, Client ID support, KMS-backed signing through the standard crypto.Signer interface, or a smaller dependency tree.
tokenSource := githubauth.NewPersonalAccessTokenSource(os.Getenv("GITHUB_TOKEN"))
httpClient := oauth2.NewClient(context.Background(), tokenSource)Works with both classic (ghp_...) and fine-grained (github_pat_...) tokens.
WithEnterpriseURLβ GitHub Enterprise Server (GHES). The URL is normalized the way GHES expects, appending/api/v3/when needed.WithBaseURLβ the URL is used verbatim. Fits GitHub Enterprise Cloud with data residency (https://api.SUBDOMAIN.ghe.com/) or anhttptestserver in tests.
githubauth.NewInstallationTokenSource(installationID, appTokenSource,
githubauth.WithEnterpriseURL("https://github.example.com"))
githubauth.NewInstallationTokenSource(installationID, appTokenSource,
githubauth.WithBaseURL("https://api.octocorp.ghe.com"))WithHTTPClient combines with either URL option in any order; given both WithBaseURL and WithEnterpriseURL, the last wins. An unparseable URL (or a nil client passed to WithHTTPClient) is reported by the first Token() call instead of silently falling back to the public GitHub API.
oauth2.ReuseTokenSource refreshes a cached token 10s before it expires, which is not always enough for a request to reach GitHub in time. Both constructors instead wrap their sources in ReuseTokenSourceWithSkew, refreshing skew before expiry (default DefaultExpirySkew, 30s). It is oauth2's own cache with a longer window, so oauth2.NewClient uses it as is instead of adding its 10s one on top.
appTokenSource, err := githubauth.NewApplicationTokenSource(clientID, privateKey,
githubauth.WithApplicationTokenExpiration(5*time.Minute),
githubauth.WithExpirySkew(5*time.Second), // effective validity: 3m55s
)Expiration is backdated 60s for clock drift, so effective validity is expiration - 60s - skew. Values at or below 90s (the backdate plus DefaultExpirySkew) are rejected and fall back to 10 minutes, because below that the cache can never hold the token and every call re-signs.
A zero or negative skew restores exact oauth2.ReuseTokenSource behavior. The wrapper is exported as ReuseTokenSourceWithSkew for use with any third-party oauth2.TokenSource, and is safe for concurrent use.
NewApplicationTokenSourceFromSigner accepts any RSA-backed crypto.Signer, so the App private key never touches process memory. GitHub requires RS256; non-RSA signers are rejected at construction time.
// signer: *rsa.PrivateKey, or a wrapper for AWS KMS, GCP KMS, Azure Key
// Vault, Vault Transit, a PKCS#11 HSM, or ssh-agent.
appTokenSource, err := githubauth.NewApplicationTokenSourceFromSigner(clientID, signer)All major backends support the required RSASSA_PKCS1_V1_5_SHA_256 operation: AWS KMS, GCP KMS, Azure Key Vault, Vault Transit, and PKCS#11 via crypto11. Community crypto.Signer adapters: form3tech-oss/jwt-go-aws-kms, salrashid123/signer.
The webhook subpackage verifies the X-Hub-Signature-256 header (HMAC-SHA256, constant time) and ships middleware that restores the body for downstream handlers. Failed verifications short-circuit with 401; oversized bodies return 413.
secret := []byte(os.Getenv("GITHUB_WEBHOOK_SECRET"))
mux := http.NewServeMux()
mux.HandleFunc("/webhook", handleWebhook) // body is already authenticated here
log.Fatal(http.ListenAndServe(":8080", webhook.Middleware(secret)(mux)))Options: webhook.WithMaxPayloadSize(n) (default 25 MiB, GitHub's delivery cap) and webhook.WithErrorHandler(fn).
Outside net/http (Lambda, queues), use webhook.Verify directly:
if err := webhook.Verify(secret, body, signature); err != nil {
// branch with errors.Is: webhook.ErrMissingSignature,
// webhook.ErrInvalidSignatureFormat, webhook.ErrSignatureMismatch
}Contributions are welcome! Please open an issue or submit a pull request on GitHub. If this package is useful to you, a star helps others discover it.
This project is licensed under the MIT License. See the LICENSE file for details.