README
ΒΆ
ghtkn (GH-Token)
Stop risking token leaks - Use secure, short-lived GitHub tokens for local development
β The Security Problem
Are you still using Personal Access Tokens (PATs) or GitHub CLI OAuth tokens stored on your local machine? These long-lived tokens pose significant security risks:
- Indefinite or months-long validity - A leaked token remains dangerous for extended periods
- Broad permissions - Often configured with wide access for convenience
- Difficult to rotate - Manual management leads to tokens being used far longer than they should
β The ghtkn Solution
ghtkn generates 8-hour User Access Tokens from GitHub Apps using Device Flow - a fundamentally more secure approach:
- Short-lived tokens - Only 8 hours validity minimizes damage from any potential leak
- No secrets required - Only needs a Client ID (which isn't secret), no Private Keys or Client Secrets
- User-attributed actions - Operations are performed as you, not as an app
- Automatic token management - Integrates with the backend (the default is OS keyring) for secure storage and reuse
ghtkn (pronounced GH-Token) allows you to manage multiple GitHub Apps through configuration files and securely store tokens using OS keyring (Windows Credential Manager, macOS Keychain, or GNOME Keyring) or another backend.
π Getting Started
- Install ghtkn
- Create a GitHub App
- Enable Device Flow
- Disable Webhook
- Homepage URL: https://github.com/suzuki-shunsuke/ghtkn (You can change this freely. If you share the GitHub App in your development team, it's good to prepare the document and set it to Homepage URL)
Only on this account- Permissions: Nothing
- Repositories: Nothing
You don't need to create secrets such as Client Secrets and Private Keys.
- Create a configuration file by
ghtkn initand modify it
ghtkn init
- Windows:
%APPDATA%\ghtkn\ghtkn.yaml - macOS, Linux:
${XDG_CONFIG_HOME:-${HOME}/.config}/ghtkn/ghtkn.yaml
apps:
- name: suzuki-shunsuke/none
client_id: xxx # Mandatory. GitHub App Client ID
[!NOTE]
The GitHub App Client ID is not a secret, so there's generally no problem writing it in plain text in local configuration files.
- Run
ghtkn getand create a user access token
ghtkn get
https://github.com/login/device will open in your browser, so enter the code displayed in the terminal and approve it.
Then a user access token starting with ghu_ is outputted.
You can close the opened tab.
With Device Flow, access tokens cannot be generated in non-interactive environments like CI. ghtkn is primarily intended for local development.
If you run the same command immediately, it will now run without the authorization flow because ghtkn stores access tokens into the backend and reuse them.
ghtkn get
- Run
gh issue createusing the access token
REPO=suzuki-shunsuke/ghtkn # Please change this to your public repository
env GH_TOKEN=$(ghtkn get) gh issue create -R "$REPO" --title "Hello, ghtkn" --body "This is created by ghtkn"
Then it fails due to the permission error even if you have the permission.
GraphQL: Resource not accessible by integration (createIssue)
Please grant the permission issues:write to the GitHub App and run again, then it still fails.
Please install the app to the repository and run again, then it succeeds.
At this time, the issue creator will be you, not the App.
The permissions (Permissions and Repositories) of a user access token are held by both the authorized user (i.e. you) and the GitHub App. Therefore, as shown above, the GitHub App cannot perform operations that it is not permitted to perform, and conversely, the user cannot perform operations that they are not authorized to perform.
Wrapping commands
You can wrap commands using shell functions or scripts.
Shell functions:
gh() {
env GH_TOKEN=$(ghtkn get) command gh "$@" # Be careful to use 'command' to avoid infinite loops
}
Shell scripts:
- Put shell scripts in $PATH:
e.g. ~/bin/gh:
#!/usr/bin/env bash
set -eu
# If GH_TOKEN or GITHUB_TOKEN is set, use it.
if [ -z "${GH_TOKEN:-}" ] && [ -z "${GITHUB_TOKEN:-}" ]; then
# echo "[WARN] skip ghtkn because GH_TOKEN or GITHUB_TOKEN is set" >&2
GH_TOKEN="$(ghtkn get)"
export GH_TOKEN
fi
exec /opt/homebrew/bin/gh "$@" # Specify the absolute path to avoid infinite loop
If the command is managed by aqua, aqua exec is useful:
exec aqua exec -- gh "$@"
- Make scripts executable
chmod +x ~/bin/gh
It's useful to wrap gh using shell script as gh always requires GitHub access tokens.
Git Credential Helper
ghtkn >= v0.1.2
You can use ghtkn as a Git Credential Helper:
git config --global credential.helper '!ghtkn git-credential'
[credential]
helper =
helper = !ghtkn git-credential
[!IMPORTANT]
helper =is necessary to disable other helpers. https://git-scm.com/docs/gitcredentials#_configuration_optionsIf credential.helper is configured to the empty string, this resets the helper list to empty (so you may override a helper set by a lower-priority config file by configuring the empty-string helper, followed by whatever set of helpers you would like).
Switching GitHub Apps by repository owner
If you want to switch GitHub Apps by repository owner,
- Set
.apps[].git_ownerin a configuration file - Configure Git
git config credential.useHttpPath true
git config --global credential.useHttpPath true
apps:
- name: suzuki-shunsuke/write
client_id: xxx
git_owner: suzuki-shunsuke # Using this app if the repository owner is suzuki-shunsuke
[!WARNING]
git_ownermust be unique. Please setgit_ownerto only one app per repository owner (organization and user). For instance, if you use a read-only app and a write app for a repository owner and you want to push commits, you should setgit_ownerto the write app.apps: - name: suzuki-shunsuke/write client_id: xxx git_owner: suzuki-shunsuke # Using this app if the repository owner is suzuki-shunsuke - name: suzuki-shunsuke/read-only client_id: xxx # git_owner: suzuki-shunsuke # Don't set `git_owner` to read-only app to push commits
β Troubleshooting of Git Credential Helper on macOS
If Git Credential Helper doesn't work on macOS, please check if osxkeychain is used.
You can check the trace log of Git by GIT_TRACE=1 GIT_CURL_VERBOSE=1.
GIT_TRACE=1 GIT_CURL_VERBOSE=1 git push origin
If git outputs the following log, Git uses git-credential-osxkeychain, not ghtkn.
09:25:49.373133 git.c:750 trace: exec: git-credential-osxkeychain get
09:25:49.373152 run-command.c:655 trace: run_command: git-credential-osxkeychain get
Please check the git config.
git config --get-all --show-origin credential.helper
The following output shows osxkeychain is used by the system setting /Library/Developer/CommandLineTools/usr/share/git-core/gitconfig.
file:/Library/Developer/CommandLineTools/usr/share/git-core/gitconfig osxkeychain
file:/Users/shunsukesuzuki/.gitconfig !ghtkn git-credential
To solve the problem, please set credential.helper to the empty string.
[credential]
helper =
helper = !ghtkn git-credential
https://git-scm.com/docs/gitcredentials#_configuration_options
If credential.helper is configured to the empty string, this resets the helper list to empty (so you may override a helper set by a lower-priority config file by configuring the empty-string helper, followed by whatever set of helpers you would like).
Using Multiple Apps
You can configure multiple GitHub Apps in the apps section of the configuration file and create and use different Apps for each Organization or User.
By default, the first App in apps is used.
You can specify the App by command line argument:
ghtkn get suzuki-shunsuke/write
The value is the app name defined in the configuration file.
Alternatively, you can specify it with the environment variable GHTKN_APP.
For example, it might be convenient to switch GHTKN_APP for each directory using a tool like direnv.
I check out my repositories from https://github.com/suzuki-shunsuke into the ~/repos/src/github.com/suzuki-shunsuke directory.
I then place a .envrc file in that directory with the following content:
source_up
export GHTKN_APP=suzuki-shunsuke/write
Similarly, I place a .envrc file in ~/repos/src/github.com/aquaproj as well:
source_up
export GHTKN_APP=aquaproj/write
I've also set up a default App that has no permissions. While some might think an access token with no permissions is useless, it can still be used to read public repositories and helps you avoid hitting API rate limits compared to not using an access token at all. So, it's quite useful.
apps:
- name: suzuki-shunsuke/none
client_id: xxx
With this setup, the access token is transparently switched depending on the working directory. What's written in the .envrc is the GHTKN_APP, not the access token itself, which is safe because it's not a secret.
Access Token Regeneration
ghtkn stores generated access tokens and their expiration dates in the backend.
ghtkn get retrieves these, and if the expiration has passed, regenerates the access token through Device Flow.
The access token validity period is 8 hours.
By default, if the access token hasn't expired, it returns it, but this may result in a short-lived access token being returned.
By specifying -min-expiration (-m) <minimum required validity period. Not a datetime but remaining time>, the access token will be regenerated if its validity period is shorter than the specified duration.
ghtkn get -m 1h
2h, 30m, 30s etc. are also valid. Units are required.
You can also set this using an environment variable.
export GHTKN_MIN_EXPIRATION=10m
If you're only using the GitHub CLI to call an API, it usually finishes in an instant, so you probably won't need to set this.
However, if you're passing the access token to a script that takes, say, 30 minutes to run, setting it to something like 50m will prevent the token from expiring in the middle of the script.
By the way, if you set the value to 8 hours or more, you can replicate how ghtkn regenerates the access token.
This could be useful if you want to test how ghtkn behaves.
ghtkn auth
ghtkn auth command authenticates to GitHub and caches an access token without printing it to stdout.
It always runs the device flow to regenerate the token, regardless of any cached token.
Unlike ghtkn get, the device flow is always allowed even when GHTKN_ENABLE_DEVICE_FLOW=false.
Also unlike ghtkn get, it does not accept -min-expiration (-m) nor read GHTKN_MIN_EXPIRATION.
Disabling Device Flow
ghtkn obtains a GitHub App User access token via the OAuth Device Flow, which is interactive: it prints a one-time (user) code and waits for the user.
A coding agent (or any background / non-interactive process) cannot complete this, so it would block until a device code expires.
The device flow is enabled by default.
By setting GHTKN_ENABLE_DEVICE_FLOW to false, ghtkn will fail fast with an actionable error instead of blocking.
ghtkn get's --device-flow (-d) flag overrides the GHTKN_ENABLE_DEVICE_FLOW environment variable.
ghtkn auth also re-authenticates.
ghtkn get -d
Backend
By default ghtkn stores access tokens in the OS keyring.
You can change where they are stored with the GHTKN_BACKEND environment variable.
This is useful in environments where the OS keyring is hard to use, such as containers and microVMs.
GHTKN_BACKEND supports the following values:
keyring: OS keyring (default)text: Store tokens as plaintext filesagent: Store tokens encrypted via the ghtkn agent
For more details, see the backend documentation.
Using ghtkn in Enterprise Organizations
When using ghtkn in a company's GitHub Organization, it may not be practical for each developer to create their own GitHub App in organizations with a certain scale. In such cases, you can create a shared GitHub App and share the Client ID within the company.
User Access Tokens cannot generate tokens with permissions beyond what the user has, and users cannot impersonate others. API rate limits are also per-user.
Therefore, the risk of sharing within a limited internal space is considered to be low.
From a company's perspective, this can prevent the leakage of developers' PATs or GitHub CLI OAuth App access tokens that have access to the company's Organization. Even if a Client ID is leaked outside the company, it doesn't provide direct access to the company's Organization, and even if an access token is leaked, the risk can be minimized due to its short validity period (8 hours).
Using personal access token for one-off operations
If the GHTKN_GITHUB_TOKEN environment variable is set, ghtkn will use it as the GitHub token.
This is useful when a personal access token is required due to the limitations of user access tokens.
Go SDK
You can enable your CLI application to create GitHub User Access Tokens using ghtkn Go SDK. ghtkn itself uses this. If SDK doesn't work well, please check if the version is latest.
How does ghtkn work?
ghtkn gets and outputs an access token in the following way:
- Read command line options and environment variables
- Read a configuration file. It has pairs of app name and client id
- Determine the GitHub App
- Get the client id from the configuration file
- Get the access token by client id from the backend
- If the access token isn't found in the backend or the access token expires, creating a new access token through Device Flow. A user need to input the verification code and approve the request
- Get the authenticated user login by GitHub API for Git Credential Helper
- Store the access token, expiration date, and authenticated user login in the backend
- Output the access token
How To Revoke Access Tokens
If an access token is leaked, it must be immediately invalidated. You can confirm if the leaked access token expires or not by GitHub API.
env GH_TOKEN=$LEAKED_GITHUB_TOKEN gh api \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
/user
You can revoke access tokens by Revoke all user tokens button in the GitHub App setting page.
If you want to revoke only a specific access token, you can revoke it via GitHub API. This API requires a client secret. You should manage it securely.
If you don't want to create a client secret, you can revoke the target app from the Authorized GitHub Apps section in the user's settings page.
Revoking the app will invalidate all User Access Tokens for the user.
However, if the user reauthorizes the app, previously issued access tokens will become valid again as long as they have not yet expired.
This means the app cannot be re-enabled until the leaked access token expires (up to 8 hours).
During that time, it may be necessary to temporarily use another GitHub App instead.
Revoking a user access token by GitHub Actions
If you create a shared GitHub App and share it within the company, it's useful to allow users to revoke their access tokens themselves by GitHub Actions when their access tokens are leaked accidentally.
It's so important to revoke leaked access tokens immediately, so it's undesirable that only administrators can revoke them.
Revoking a user access token by GitHub API requires a client secret, but you should not share it widely.
Instead, you can manage it by GitHub Environment Secret or Secrets Manager such as AWS SecretsManager securely, allowing people to revoke their access tokens via workflow_dispatch workflow.
[!WARNING] Generally, passing secrets via inputs of
workflow_dispatchisn't good, but in this case it can't be helped and the passed access token is revoked so there is no problem.
GitHub Actions Workflow
---
name: Revoke a User Access Token
run-name: Revoke a User Access Token (${{github.actor}})
on:
workflow_dispatch:
inputs:
access_token:
description: 'The access token to revoke'
required: true
type: string
jobs:
revoke:
timeout-minutes: 10
runs-on: ubuntu-24.04
permissions: {}
steps:
- name: Check if the access token is available
# This step is optional.
continue-on-error: true # Continue even if the access token is unavailable
env:
GH_TOKEN: ${{inputs.access_token}} # GitHub Actions automatically masks access tokens
run: |
gh api \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
/user
- name: Revoke the access token
env:
GH_TOKEN: ${{inputs.access_token}} # GitHub Actions automatically masks access tokens
CLIENT_ID: ${{secrets.CLIENT_ID}}
CLIENT_SECRET: ${{secrets.CLIENT_SECRET}}
run: |
curl -L \
-X DELETE \
-H "Accept: application/vnd.github+json" \
-u "${CLIENT_ID}:${CLIENT_SECRET}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/applications/${CLIENT_ID}/token" \
-d '{"access_token":"'"${GH_TOKEN}"'"}'
GitHub Actions Workflow For Multiple GitHub Apps
- Register client ids and secrets to GitHub Environment Secrets, allowing only the default branch to access secrets
Secret names:
CLIENT_ID_${NAME}CLIENT_SECRET_${NAME}
---
name: Revoke a User Access Token
run-name: Revoke a User Access Token (${{github.actor}}/${{inputs.app_name}})
on:
workflow_dispatch:
inputs:
app_name:
description: GitHub App name
required: true
type: choice
default: write
options: # PLEASE CHANGE
- none
- read
- write
- full
access_token:
description: 'The access token to revoke'
required: true
type: string
jobs:
revoke:
timeout-minutes: 10
runs-on: ubuntu-24.04
permissions: {}
environment: main
steps:
- name: Check if the access token is available
# This step is optional.
continue-on-error: true # Continue even if the access token is unavailable
env:
GH_TOKEN: ${{inputs.access_token}} # GitHub Actions automatically masks access tokens
run: |
gh api \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
/user
- name: Choose the client id and client secret
id: secret_names
env:
APP_NAME: ${{inputs.app_name}}
run: |
UPPER_APP_NAME=$(echo "$APP_NAME" | tr '[:lower:]' '[:upper:]')
echo CLIENT_ID_NAME="CLIENT_ID_${UPPER_APP_NAME}" >> "$GITHUB_OUTPUT"
echo CLIENT_SECRET_NAME="CLIENT_SECRET_${UPPER_APP_NAME}" >> "$GITHUB_OUTPUT"
- name: Revoke the access token
env:
GH_TOKEN: ${{inputs.access_token}} # GitHub Actions automatically masks access tokens
CLIENT_ID: ${{secrets[steps.secret_names.outputs.CLIENT_ID_NAME]}}
CLIENT_SECRET: ${{secrets[steps.secret_names.outputs.CLIENT_SECRET_NAME]}}
run: |
curl -L \
-X DELETE \
-H "Accept: application/vnd.github+json" \
-u "${CLIENT_ID}:${CLIENT_SECRET}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/applications/${CLIENT_ID}/token" \
-d '{"access_token":"'"${GH_TOKEN}"'"}'
- name: Check if the access token has been revoked
# This step is optional.
continue-on-error: true # Continue even if the access token is unavailable
env:
GH_TOKEN: ${{inputs.access_token}} # GitHub Actions automatically masks access tokens
run: |
gh api \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
/user
Comparison between GitHub App User Access Token and other access tokens
GitHub CLI OAuth App access token
https://cli.github.com/manual/gh_auth_token
This can be easily generated with gh auth login, gh auth token in GitHub CLI.
You don't need to generate Personal Access Tokens, and it's convenient.
Also, when scopes across Users or Organizations are needed, it's difficult with non-Public GitHub Apps, but installing GitHub CLI OAuth App across multiple Users or Organizations solves such problems.
However, this access token is not very good from a security perspective. While you can restrict the scope (permission) and target Organizations, these tend to be quite broad for convenience. Also, it's basically indefinite. Therefore, the risk when this token is leaked is very high.
So, a more secure mechanism is needed.
fine-grained Personal Access Token
We'll ignore Legacy PAT as it's almost the same as OAuth App tokens.
Fine-grained access tokens have the following disadvantages compared to User Access Tokens:
- Regular rotation is cumbersome
- Management is cumbersome
- High risk when leaked
- While the validity period is not indefinite, it tends to be quite long
- Since short periods make rotation cumbersome, it tends to be 1 year or 6 months
- Not on the order of a few hours
- While the validity period is not indefinite, it tends to be quite long
GitHub App installation access token
- Pros
- Can change permissions, repositories, and validity period when generating tokens
- Cons
- Cannot operate as a User
- e.g., PR creator becomes the App
- Private Key management is cumbersome
- High risk when Private Key is leaked
- Cannot operate as a User
β Troubleshooting
ghtkn doesn't work well
- Check environment variables, ghtkn version, etc.
ghtkn info [<app name>]
If ghtkn info command isn't found or the version isn't latest, please upgrade ghtkn to the latest version.
- Check the token and expiration date.
ghtkn get -f json [<app name>]
- Check the access token is available.
env GH_TOKEN=$(ghtkn get) gh auth status
Please confirm the prefix of the token is ghu_.
If the prefix isn't ghu_, another type of token is used.
github.com
o Logged in to github.com account suzuki-shunsuke (GH_TOKEN)
- Active account: true
- Git operations protocol: https
- Token: ghu_************************************
- Check the access token is valid using curl.
curl -L \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $(ghtkn get)" \
-H "X-GitHub-Api-Version: 2026-03-10" \
https://api.github.com/user
- Check the configuration file is correct.
The wrapper of gh command doesn't work well
- Check if
ghtknworks well - Check if the wrapper is invoked correctly.
command -v gh
- Check if another access token like personal access token is set
- Add the debug log to the wrapper.
e.g.
if [ -z "${GH_TOKEN:-}" ] && [ -z "${GITHUB_TOKEN:-}" ]; then
echo "[WARN] skip ghtkn because GH_TOKEN or GITHUB_TOKEN is set" >&2 # Add the debug log
GH_TOKEN="$(ghtkn get)"
export GH_TOKEN
fi
ghtkn returns an expired token (401)
If ghtkn get returns an expired token, you can renew it by running ghtkn auth.
ghtkn auth
The device flow asks the verification code, but the code isn't shown anywhere
When ghtkn is run in the background process, the verification code is not displayed in the terminal. In that case, you need to:
- Cancel the process
A - Run
ghtkn auth [app for process A]manually to renew the access token - Re-run the process
A
As of ghtkn v0.2.5, you can prevent this kind of issue by setting GHTKN_ENABLE_DEVICE_FLOW to false.
export GHTKN_ENABLE_DEVICE_FLOW=false
When the token expires, you need to run ghtkn auth to renew it.
A browser opens when using tools like cmux and warp
When using cmux and warp, ghtkn may open a browser on its own. Worse, the one-time code isn't shown anywhere, so you can't complete the device flow and have to close the browser tab.
As of ghtkn v0.2.5, you can prevent this kind of issue by setting GHTKN_ENABLE_DEVICE_FLOW to false.
export GHTKN_ENABLE_DEVICE_FLOW=false
When the token expires, you need to run ghtkn auth to renew it.
Limitations
ghtkn obtains a user access token, but unfortunately it has some limitations so a personal access token is required for some operations.
- Packages API requires a classic personal access token
- It's difficult to write other user's repositories
Packages API requires a classic personal access token
- https://docs.github.com/en/rest/packages/packages?apiVersion=2026-03-10
-
To use the REST API to manage GitHub Packages, you must authenticate using a personal access token (classic).
It's difficult to write other user's repositories
To write other users' repositories, a GitHub App installed on the target repository and its client id is required. It's hard to ask others to install a GitHub App on their repository and share the client id with you.
For instance, it's difficult to create pull requests to other users' repositories by gh pr create command.
In that case, the --web option of gh pr create is useful.
π Note
API rate limit
Primary rate limits for GitHub App user access tokens (as opposed to installation access tokens) are dictated by the primary rate limits for the authenticated user. This rate limit is combined with any requests that another GitHub App or OAuth app makes on that user's behalf and any requests that the user makes with a personal access token. For more information, see Rate limits for the REST API.
The rate limit for authenticated users is 5,000 per hour, so it should be fine for normal use.
All of these requests count towards your personal rate limit of 5,000 requests per hour.
Directories
ΒΆ
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
ghtkn
command
|
|
|
pkg
|
|
|
cli
Package cli provides the command-line interface layer for ghtkn.
|
Package cli provides the command-line interface layer for ghtkn. |
|
cli/agent
Package agent implements the 'ghtkn agent' command and its subcommands.
|
Package agent implements the 'ghtkn agent' command and its subcommands. |
|
cli/auth
Package auth implements the 'ghtkn auth' command.
|
Package auth implements the 'ghtkn auth' command. |
|
cli/flag
Package flag provides common command-line flags for ghtkn CLI.
|
Package flag provides common command-line flags for ghtkn CLI. |
|
cli/get
Package get implements both the 'ghtkn get' command and 'ghtkn git-credential' command.
|
Package get implements both the 'ghtkn get' command and 'ghtkn git-credential' command. |
|
cli/initcmd
Package initcmd implements the 'ghtkn init' command.
|
Package initcmd implements the 'ghtkn init' command. |
|
controller/agent
Package agent provides the controller for the 'ghtkn agent' command.
|
Package agent provides the controller for the 'ghtkn agent' command. |
|
controller/get
Package get provides functionality to retrieve GitHub App access tokens.
|
Package get provides functionality to retrieve GitHub App access tokens. |
|
controller/initcmd
Package initcmd implements the business logic for the 'ghtkn init' command.
|
Package initcmd implements the business logic for the 'ghtkn init' command. |
|
log
Package log provides structured logging functionality for ghtkn.
|
Package log provides structured logging functionality for ghtkn. |