Problem statement:
We are afraid of PATs in the org. Once given they essentially never expire and can do untold things to whatever it has access to. Oh yeah, and they can’t really be tracked.
A mirage
I thought this was an open and shut case. I stumbled across this blog post from Shopify: Automatically Rotating GitHub Tokens (So You Don’t Have To). “Awesome a known-tech org with a ready-to-go enterprise solution.”
Nope… That part is closed-source. So I guess we do have to.
Their recommendation is essentially:
-
Create a centralized GitHub App for your organization with a meaningful name. This name will show up as the actor in things like Issue Timelines, so make sure the name is at least somewhat meaningful.
-
Give your new App the superset of all expected permissions. Apps can only give their tokens a subset of their own permissions, so try to determine the largest possible scope to fill all downstream use cases. This is configurable at any time by an organization admin, so no need to get this perfect the first time (we had to adjust permissions 4-5 times!)
-
Install the App on the organization, giving it access to All Repositories.
-
Create a centralized repository that will host all the rotation workflows and the rotation job action code.
-
Place the App’s secrets into the repository Secrets. This includes items like the private key, the client secret, etc.
-
Write a custom action in the repository that will handle a single token rotation. Our version is written in Typescript (compiled down to Javascript for the Action runtime) and is unfortunately closed-source.
The Action accepts the following parameters:
Private key
Application ID
Client ID
Client Secret
App Installation ID
Repository Organization (ours defaults to “Shopify”)
Repository Name (the location of the token)
Accessible Repositories (the repos the token has access to)
Permissions (a JSON blob of permission to level of access)
Target Secret Name (the name of the token as will be placed in the target repository)
The Action takes the following steps:
- Authenticate to GitHub as the App by generating a signed JWT
- Generate two installation tokens–one to place into the repository as the rotated secret and one with the “secrets:write” permission scope to validly authenticate the placing of the secret into the repository.
- Get the target repository’s public key in order to properly encrypt the secret.
- Encrypt the rotated secret and place it into the target repository.
- Revoke the generated installation token that was used to authenticate the secrets update call.
I am not exactly in love with all of that. Seems like a lot, I was hoping for something more straight forward.
The Desert
Well I guess its time to re-visit what GitHub actually offers its users. Unfortunately I saw what I always see when I run into some immersion-breaking, missing feature in GitHub where a functionality that seems so second-nature is no where to be found accompanied by a GitHub Issues thread that is years-old, full of users complaining about its omission.
Quick note though, I did see that you can control PATs settings in your org to expire within one day. Not even remotely the experience I want, but nice I guess.

Almost
I found this repo: ghtkn: A CLI to create short-lived (8 hours) GitHub App User Access Token for secure local development. Putting aside the security vulnerability of having a random open-source piece of software control secrets for my entire org; its promises seem to be exactly what I want.
I wanted to see in-depth how it worked.
ghtkn (GH-Token) is a CLI tool that generates GitHub App User Access Tokens valid for exactly 8 hours. The 8-hour lifetime is not configured by ghtkn itself — it is imposed by GitHub’s API for GitHub App User Access Tokens. ghtkn’s job is to: (1) drive the OAuth Device Flow to obtain that token, (2) cache it in the OS keyring, and (3) serve it from cache on subsequent calls until it expires.
The Token Lifecycle at a Glance
ghtkn get
│
├─► Read config (app name → client_id)
│
├─► Check OS keyring for cached token
│ └─ Valid? → return it immediately (no network call)
│
└─► No valid token → OAuth Device Flow
├─ POST /login/device/code (get user code)
├─ Show code to user + open browser
├─ Poll /login/oauth/access_token (wait for user approval)
└─ Cache token (access_token + expiration_date) in keyring
Step 1 — Entry Point: TokenManager.Get
File: ghtkn-go-sdk/ghtkn/internal/api/get.go:51
func (tm *TokenManager) Get(ctx context.Context, logger *slog.Logger, input *InputGet) (*keyring.AccessToken, *config.App, error) {
// 1. Resolve config file path (env var GHTKN_CONFIG → XDG default → OS-specific default)
configPath := input.ConfigFilePath
if configPath == "" {
p, err := config.GetPath(tm.input.Getenv, tm.input.GOOS)
// ...
configPath = p
}
// 2. Read YAML config; find the matching GitHub App entry by name or GHTKN_APP env var
if err := tm.readConfig(cfg, configPath); err != nil { ... }
appName := input.AppName
if appName == "" {
appName = tm.input.Getenv("GHTKN_APP") // e.g. "suzuki-shunsuke/write"
}
app := config.SelectApp(cfg, appName, input.AppOwner) // resolves client_id
// 3. Keyring namespace — defaults to "github.com/suzuki-shunsuke/ghtkn"
keyringService := input.KeyringService
if keyringService == "" {
keyringService = keyring.DefaultServiceKey
}
// 4. Core logic: return cached token or create a new one
token, changed, err := tm.getOrCreateToken(ctx, logger, &inputGetOrCreateToken{
KeyringService: keyringService,
MinExpiration: input.MinExpiration, // e.g. 1h — see section 5
App: app,
})
// 5. If the token was freshly created, it won't have a Login yet.
// Git Credential Helper needs both username and password, so fetch it now.
if token.Login == "" {
gh := tm.input.NewGitHub(ctx, token.AccessToken)
user, err := gh.GetUser(ctx)
token.Login = user.Login
}
// 6. Persist newly created (or login-enriched) token back to keyring
if changed {
tm.input.Keyring.Set(keyringService, app.ClientID, &keyring.AccessToken{
AccessToken: token.AccessToken,
ExpirationDate: token.ExpirationDate, // set to Now() + 28800s by GitHub
Login: token.Login,
})
}
return token, app, nil
}
Step 2 — Cache Check: getOrCreateToken
File: ghtkn-go-sdk/ghtkn/internal/api/get.go:151
func (tm *TokenManager) getOrCreateToken(...) (*keyring.AccessToken, bool, error) {
// Try the keyring first. Returns nil if missing OR if it would expire
// within the MinExpiration window (e.g. "refresh if < 1h remaining").
if token := tm.getAccessTokenFromKeyring(logger, input.KeyringService, input.App.ClientID, input.MinExpiration); token != nil {
return token, false, nil // false = "not changed", skip keyring write
}
// Cache miss (or expired) → kick off Device Flow
token, err := tm.createToken(ctx, logger, input.App.ClientID)
// ...
return token, true, nil // true = "changed", caller will write to keyring
}
The Expiry Check
// getAccessTokenFromKeyring — get.go:179
func (tm *TokenManager) getAccessTokenFromKeyring(..., minExpiration time.Duration) *keyring.AccessToken {
tk, err := tm.input.Keyring.Get(keyringService, key) // JSON blob from OS keyring
if tk == nil { return nil } // not found
// checkExpired returns true if Now() + minExpiration > ExpirationDate
// i.e. "the token will expire before minExpiration elapses"
if tm.checkExpired(tk.ExpirationDate, minExpiration) {
return nil // treat as expired → trigger re-auth
}
return tk // still valid
}
// checkExpired — get.go:202
func (tm *TokenManager) checkExpired(exDate time.Time, minExpiration time.Duration) bool {
// Adding minExpiration to now lets users say "renew if less than X remains".
// Default minExpiration is 0, so any non-expired token is returned.
return tm.input.Now().Add(minExpiration).After(exDate)
}
The MinExpiration flag (-m 1h) is the only user-controlled knob. GitHub’s 8-hour hard limit is not configurable.
Step 3 — OAuth Device Flow Phase 1: Get Device Code
File: ghtkn-go-sdk/ghtkn/internal/deviceflow/create.go:66
// Create() is called when no valid cached token exists.
// It orchestrates the two-phase Device Flow.
func (c *Client) Create(ctx context.Context, logger *slog.Logger, clientID string) (*AccessToken, error) {
// Phase 1: request a device code + user code from GitHub
deviceCode, err := c.getDeviceCode(ctx, clientID)
// Calculate when the device code itself expires (typically 15 min / 900s)
deviceCodeExpirationDate := c.input.Now().Add(time.Duration(deviceCode.ExpiresIn) * time.Second)
// Show the user code in the terminal (e.g. "ABCD-1234")
c.input.DeviceCodeUI.Show(ctx, logger, deviceCode, deviceCodeExpirationDate)
// Attempt to open https://github.com/login/device in the default browser
c.input.Browser.Open(ctx, logger, deviceCode.VerificationURI)
// Phase 2: poll until the user approves or the device code expires
token, err := c.pollForAccessToken(ctx, logger, clientID, deviceCode)
// Compute expiration: Now() + ExpiresIn (28800 = 8 hours, set by GitHub)
return &AccessToken{
AccessToken: token.AccessToken,
ExpirationDate: c.input.Now().Add(time.Duration(token.ExpiresIn) * time.Second),
}, nil
}
The Device Code Request
// getDeviceCode — create.go:66
func (c *Client) getDeviceCode(ctx context.Context, clientID string) (*DeviceCodeResponse, error) {
// Only the Client ID is sent — no client secret, no private key
jsonData, _ := json.Marshal(map[string]string{"client_id": clientID})
req, _ := http.NewRequestWithContext(ctx, http.MethodPost,
"https://github.com/login/device/code", // GitHub Device Flow endpoint
bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json") // ensures JSON response (not form-encoded)
resp, _ := c.input.HTTPClient.Do(req)
// ...
deviceCode := &DeviceCodeResponse{}
json.Unmarshal(body, deviceCode)
return deviceCode, nil
}
DeviceCodeResponse struct (token.go:74):
type DeviceCodeResponse struct {
DeviceCode string `json:"device_code"` // opaque code sent back during polling
UserCode string `json:"user_code"` // human-readable code shown to user (e.g. "ABCD-1234")
VerificationURI string `json:"verification_uri"` // always "https://github.com/login/device"
ExpiresIn int `json:"expires_in"` // device code lifetime in seconds (≈900)
Interval int `json:"interval"` // minimum polling interval in seconds (≈5)
}
Step 4 — OAuth Device Flow Phase 2: Poll for Access Token
File: ghtkn-go-sdk/ghtkn/internal/deviceflow/create.go:117
const additionalInterval = 5 * time.Second // hard floor on polling cadence
func (c *Client) pollForAccessToken(ctx context.Context, logger *slog.Logger, clientID string, deviceCode *DeviceCodeResponse) (*AccessTokenResponse, error) {
// Respect GitHub's requested interval; never poll faster than 5s
interval := time.Duration(deviceCode.Interval) * time.Second
if interval < additionalInterval {
interval = additionalInterval
}
ticker := c.input.NewTicker(interval)
defer ticker.Stop()
// Deadline = now + device code lifetime (≈15 min).
// If the user doesn't approve in time, the poll loop aborts.
deadline := c.input.Now().Add(time.Duration(deviceCode.ExpiresIn) * time.Second)
for {
select {
case <-ctx.Done():
return nil, fmt.Errorf("context was cancelled: %w", ctx.Err())
case <-ticker.C:
if c.input.Now().After(deadline) {
return nil, errors.New("device code expired")
}
token, err := c.checkAccessToken(ctx, clientID, deviceCode.DeviceCode)
if err != nil {
if err.Error() == "authorization_pending" {
// User hasn't approved yet — keep waiting
logger.Debug("device flow's authorization is still pending")
continue
}
if err.Error() == "slow_down" {
// GitHub told us we're polling too fast — bump the interval by 5s
ticker.Reset(interval + 5*time.Second)
continue
}
return nil, err // real error
}
if token != nil {
return token, nil // success — token contains ExpiresIn = 28800
}
}
}
}
The Token Request
// checkAccessToken — create.go:160
func (c *Client) checkAccessToken(ctx context.Context, clientID, deviceCode string) (*AccessTokenResponse, error) {
reqBody := map[string]string{
"client_id": clientID,
"device_code": deviceCode,
// Standard RFC 8628 grant type for Device Authorization Grant
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
}
req, _ := http.NewRequestWithContext(ctx, http.MethodPost,
"https://github.com/login/oauth/access_token",
bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, _ := c.input.HTTPClient.Do(req)
// ...
token := &AccessTokenResponse{}
json.Unmarshal(body, token)
if token.Error != "" {
// Propagates "authorization_pending" and "slow_down" as plain error strings
// so pollForAccessToken can branch on them above
return nil, errors.New(token.Error)
}
return token, nil
}
AccessTokenResponse struct (token.go:84):
type AccessTokenResponse struct {
AccessToken string `json:"access_token"` // "ghu_..." token, valid for 8 hours
ExpiresIn int `json:"expires_in"` // 28800 — 8 × 3600; set by GitHub, not ghtkn
Error string `json:"error"` // "authorization_pending" | "slow_down" | ""
}
Step 5 — Where the 8 Hours Actually Come From
The 8-hour lifetime is not a constant in this codebase. It is the value GitHub returns in ExpiresIn for GitHub App User Access Tokens (as opposed to installation tokens, which are 1 hour). The code simply trusts whatever GitHub sends:
// create.go:58-61 — the only place the expiration is written
return &AccessToken{
AccessToken: token.AccessToken,
// ExpiresIn is received from GitHub as 28800 (8 × 3600 seconds)
ExpirationDate: now.Add(time.Duration(token.ExpiresIn) * time.Second),
}, nil
The tests confirm the expected value but it never appears as a literal in production code:
// token_internal_test.go (test data)
resp := AccessTokenResponse{
AccessToken: "ghu_testtoken123",
ExpiresIn: 28800, // 8 hours — what GitHub actually returns
}
Step 6 — Token Storage: OS Keyring
File: ghtkn-go-sdk/ghtkn/internal/keyring/keyring.go
// AccessToken is the JSON blob written to the OS keyring.
// Service key: "github.com/suzuki-shunsuke/ghtkn"
// Username key: the GitHub App's client_id
type AccessToken struct {
AccessToken string `json:"access_token"` // "ghu_..." value
ExpirationDate time.Time `json:"expiration_date"` // RFC3339; used by checkExpired()
Login string `json:"login"` // GitHub username (for Git Credential Helper)
}
// Set serialises to JSON and writes to the system keyring.
// On macOS → Keychain, Windows → Credential Manager, Linux → GNOME Keyring / libsecret
func (kr *Keyring) Set(service, key string, token *AccessToken) error {
s, _ := json.Marshal(token)
return kr.input.API.Set(service, key, string(s))
}
// Get deserialises from the keyring and validates fields.
// Returns nil (not error) when the key doesn't exist — callers treat nil as "needs new token".
func (kr *Keyring) Get(service string, key string) (*AccessToken, error) {
s, exist, _ := kr.input.API.Get(service, key)
if !exist {
return nil, nil
}
token := &AccessToken{}
json.Unmarshal([]byte(s), token)
token.Validate() // requires all three fields to be non-zero
return token, nil
}
Complete Data Flow Diagram
┌─────────────────────────────────────────────────────────────────┐
│ ghtkn get │
└──────────────────────────────┬──────────────────────────────────┘
│
┌──────────▼──────────┐
│ Read ghtkn.yaml │
│ → resolve client_id│
└──────────┬──────────┘
│
┌──────────▼──────────┐
│ OS Keyring lookup │
│ key = client_id │
└──────────┬──────────┘
│
┌─────────────────┴──────────────────┐
│ Found + not expired? │
│ (Now + MinExpiration < ExpiresAt) │
└─────────────────┬──────────────────┘
YES │ NO
│ │
┌──────────▼──┐ ┌───▼──────────────────────────────────┐
│ Return token│ │ OAuth Device Flow │
└─────────────┘ │ │
│ POST /login/device/code │
│ body: { client_id } │
│ ← { device_code, user_code, │
│ expires_in≈900, interval≈5 } │
│ │
│ Show user_code in terminal │
│ Open https://github.com/login/device │
│ │
│ Loop every ~5s: │
│ POST /login/oauth/access_token │
│ body: { client_id, device_code, │
│ grant_type: device_code } │
│ ← "authorization_pending" → wait │
│ ← "slow_down" → increase interval │
│ ← { access_token, │
│ expires_in: 28800 } → done │
│ │
│ ExpirationDate = Now() + 28800s │
│ (= Now + 8 hours, from GitHub) │
│ │
│ Fetch GitHub login via API │
│ Store JSON blob in OS keyring │
└───────────────────────────────────────┘
Key Design Decisions
| Aspect | Detail |
|---|---|
| 8-hour lifetime | Dictated by GitHub’s API for App User Access Tokens. ghtkn reads it from expires_in in the response — there is no hardcoded value in production code. |
| No refresh tokens | GitHub’s Device Flow doesn’t issue refresh tokens. When a token expires, the entire Device Flow is re-run. |
| No client secret | Only the public client_id is ever sent. This is the security proposition: nothing sensitive is stored or transmitted. |
MinExpiration flag | Lets users pre-emptively renew. checkExpired treats a token as expired if Now() + MinExpiration > ExpirationDate, so -m 1h ensures the returned token has at least 1 hour of life remaining. |
slow_down handling | When GitHub returns slow_down, the interval is increased by 5 seconds per occurrence. The new interval is never reset back down in the same polling session. |
| Keyring key | Tokens are keyed by client_id under the service namespace "github.com/suzuki-shunsuke/ghtkn". Multiple GitHub Apps can therefore coexist in the same keyring. |
Apologies to future Tyler for the AI slop, but I didn’t really want to peel through all that Go.
TLDR:
- Audit log streaming - I am not sure if this feature is new or we just missed it, but GitHub allows us to stream our audit log to an external DB. Splunk is named!