GitHub Apps and OAuth Apps Get Refresh Tokens: How to Migrate Off Long-Lived Access Tokens
GitHub Apps and OAuth Apps Get Refresh Tokens: How to Migrate Off Long-Lived Access Tokens
If you've built an OAuth App or a GitHub App that authenticates on behalf of users, there's a decent chance it's still handing out a gho_ or ghu_ token that never expires. On August 14, 2026, GitHub shipped token rotation for both app types: user access tokens now expire in 8 hours, refresh tokens last 6 months, and you get up to 10 redirect URIs per app instead of one. This walks through what changed, why the old model was a real liability, and exactly how to wire up the refresh flow.
Why a non-expiring user token was a problem
Before this change, a GitHub App with "user-to-server token expiration" left at its default โ or an OAuth App generating a classic gho_ token โ issued a token that lived forever until someone manually revoked it. That token typically ends up in a database, a session store, or an environment variable somewhere in your stack. If that storage layer leaks โ a misconfigured backup, an exposed .env, a compromised CI runner โ the attacker doesn't get an 8-hour window, they get standing access until someone notices and revokes it by hand.
Short-lived access tokens with a refresh flow flip that calculus. A leaked ghu_ token is worthless within hours even if nobody notices the breach, and a leaked refresh token is a single-use credential โ GitHub explicitly invalidates both the old refresh token and the old access token the moment you redeem a refresh token, so a stolen refresh token that gets used by your app first locks the attacker out on your next legitimate refresh.
What actually shipped on August 14, 2026
Three separate but related changes landed together:
- Token rotation โ user access tokens expire after 8 hours (
28800seconds); refresh tokens are valid for 6 months (15897600seconds). New GitHub Apps get this enabled by default; existing apps have to opt in. - Multiple redirect URIs โ OAuth Apps can now register up to 10 redirect URIs instead of exactly one, useful if you run staging, preview, and production callback URLs off the same app registration.
- Wildcard redirect matching review โ wildcard matching on redirect URIs (matching subdomains or extra path segments against your configured URI) is available for both app types, but GitHub is flagging that apps which only ever had one redirect URI had wildcard matching on by default under the old model. That's worth auditing on its own, independent of the token work.
This is available on github.com now and is coming to GitHub Enterprise Server 3.23.
Step 1 โ Turn on expiring tokens for a GitHub App
If your GitHub App was created before this rollout, expiring tokens are opt-in:
- Go to your app's settings โ personal account: Settings โ Developer settings โ GitHub Apps; organization-owned app: Org Settings โ Developer settings โ GitHub Apps.
- Select your app, click Edit.
- Under Optional features, find User-to-server token expiration and switch it to Opt-in.
New GitHub Apps get this on by default unless you explicitly opt out at creation time. For OAuth Apps (the older, simpler app type without installation-based permissions), the equivalent switch is requesting the offline_access scope in your authorization URL:
1https://github.com/login/oauth/authorize?client_id=YOUR_CLIENT_ID&scope=repo%20offline_access
Without offline_access, an OAuth App keeps getting the old-style non-expiring gho_ token โ the scope is what actually triggers the short-lived-token behavior.
Step 2 โ Handle the initial token exchange (unchanged)
The authorization code exchange itself doesn't change. You still POST the code you received to get back a token pair โ the difference is the response now includes refresh token fields:
1curl -X POST https://github.com/login/oauth/access_token \
2 -H "Accept: application/json" \
3 -d client_id="$CLIENT_ID" \
4 -d client_secret="$CLIENT_SECRET" \
5 -d code="$AUTH_CODE"
1{
2 "access_token": "ghu_16C7e42F292c6912E7710c838347Ae178B4a",
3 "expires_in": 28800,
4 "refresh_token": "ghr_1B4a2e77838347a7E420ce178F2E7c6912C7fe16",
5 "refresh_token_expires_in": 15897600,
6 "scope": "",
7 "token_type": "bearer"
8}
Store both access_token and refresh_token, plus a computed expiry timestamp โ don't rely on wall-clock guessing later.
Step 3 โ Implement the refresh flow
When a request fails with an expired-token error (or, better, proactively when your stored expiry is within a few minutes of now), exchange the refresh token for a new pair:
1curl -X POST https://github.com/login/oauth/access_token \
2 -H "Accept: application/json" \
3 -d client_id="$CLIENT_ID" \
4 -d client_secret="$CLIENT_SECRET" \
5 -d grant_type="refresh_token" \
6 -d refresh_token="$STORED_REFRESH_TOKEN"
The response has the same shape as the initial exchange โ a new access_token, a new refresh_token, and fresh expires_in/refresh_token_expires_in values. Overwrite both stored tokens; the old pair stops working the instant you redeem the refresh token. If two processes race to refresh the same user's token concurrently, only the first request wins โ the second gets an invalid-token error, so put a lock or a single-writer path around your refresh logic if your app has more than one process handling the same user session.
A minimal pattern in Node:
1async function getValidAccessToken(user) {
2 if (Date.now() < user.accessTokenExpiresAt - 60_000) {
3 return user.accessToken;
4 }
5 const res = await fetch("https://github.com/login/oauth/access_token", {
6 method: "POST",
7 headers: { Accept: "application/json", "Content-Type": "application/json" },
8 body: JSON.stringify({
9 client_id: process.env.GITHUB_CLIENT_ID,
10 client_secret: process.env.GITHUB_CLIENT_SECRET,
11 grant_type: "refresh_token",
12 refresh_token: user.refreshToken,
13 }),
14 });
15 const tokens = await res.json();
16 await db.updateUserTokens(user.id, tokens);
17 return tokens.access_token;
18}
Step 4 โ Handle refresh token expiry
Six months is long, but it's not forever โ if a user's session sits completely idle past that window, the refresh token expires too and you're back to a full OAuth authorization redirect. Design for it: catch the specific error, clear the stored tokens, and send the user back through /login/oauth/authorize rather than surfacing a raw 401 from a background job.
Step 5 โ Add and audit redirect URIs
While you're in the app settings, add any additional callback URLs you need (staging, preview deploys, a second domain) via the new Add redirect URI button โ up to 10 total. Then check wildcard matching specifically: if your app only ever had one redirect URI registered, it may have wildcard matching enabled from the old default behavior. Unless you deliberately need subdomain or extra-path matching, turn it off โ a redirect URI that matches more than the exact callback path you control is an open redirect risk if any part of your domain is user-influenced.
Best practices
- Refresh proactively, not reactively. Check the stored expiry before making the request rather than waiting for a 401 โ it avoids failing the user's actual action while you silently refresh in the background.
- Never log tokens.
access_tokenandrefresh_tokenvalues are equivalent to credentials; treat them the same as you would a database password in your logging and error-reporting pipeline. - Serialize refreshes per user. Concurrent refresh attempts for the same token pair will have exactly one winner โ build a lock (Redis, a DB row lock, whatever your stack already has) instead of discovering this in production.
- Rotate
client_secreton a schedule regardless. Token expiration protects the user-token layer; it doesn't protect you if your app's own client secret leaks.
Common mistakes to avoid
- Forgetting
offline_accesson the OAuth App authorize URL. Without it, you silently keep getting the old non-expiring token and never notice you're not actually on the new model. - Not updating the stored token after a refresh. The old refresh token is invalidated the moment you use it โ if you refresh but don't persist the new pair, the next refresh attempt fails outright.
- Treating this as a GitHub Actions change. This is about GitHub Apps and OAuth Apps authenticating users (
ghu_/gho_tokens), not theGITHUB_TOKENyour Actions workflows use โ those are a separate, already short-lived mechanism. - Assuming Enterprise Server has it today. It's rolling out to GHES in version 3.23; check your instance version before you build a migration plan around it.
Troubleshooting
Refresh request returns bad_refresh_token. The refresh token was already used (rotation invalidated it), has expired past its 6-month window, or was copy-pasted with whitespace/truncation. Re-authorize the user from scratch.
Access token still doesn't expire after opting in. Confirm you're checking expires_in on tokens issued after you flipped the setting or added offline_access โ tokens already issued under the old model keep their original (non-expiring) behavior until the user re-authorizes.
Getting redirect_uri_mismatch after adding a new URI. Registered redirect URIs must match exactly (scheme, host, path) unless wildcard matching is explicitly enabled for that entry โ double-check trailing slashes, they're a common mismatch.
FAQ
Does this affect the GITHUB_TOKEN used inside GitHub Actions workflows?
No. That token is a separate, already-scoped-and-expiring mechanism tied to a single workflow run. This change is about the OAuth/GitHub App user authentication flow โ tokens like ghu_ and gho_ issued when a human authorizes your app.
Do I have to migrate immediately?
Not for existing apps โ expiring tokens are opt-in for GitHub Apps and gated behind the offline_access scope for OAuth Apps. But GitHub is clearly steering the ecosystem this direction, and non-expiring user tokens are a growing liability the longer you leave them as-is.
What happens to sessions mid-flight when I flip the setting? Tokens already issued keep working under their original behavior. New tokens issued after the change follow the new expiring model. There's no forced re-authorization of existing users just from toggling the setting.
Can I use refresh tokens with a GitHub App installation token (server-to-server), not just user tokens? No โ installation access tokens (the ones your app uses to act on a repository without a specific user in the loop) are already short-lived by design (1 hour) and use a different, JWT-based minting flow, not this refresh-token exchange.
Is 10 redirect URIs a hard cap? Yes, per the current rollout. If you genuinely need more than 10 distinct callback URLs on one app, that's usually a sign to split into separate app registrations per environment instead.
Key takeaways
| Change | Old behavior | New behavior |
|---|---|---|
| User access token lifetime | Effectively non-expiring | 8 hours (28800s) |
| Refresh token lifetime | N/A | 6 months (15897600s) |
| How to enable (GitHub App) | N/A | Opt-in under "Optional features" |
| How to enable (OAuth App) | N/A | Add offline_access to the authorize scope |
| Redirect URIs per app | 1 | Up to 10 |
| Wildcard redirect matching | On by default for single-URI apps | Available, but worth auditing/disabling |
Further Reading
- Docker Hub OIDC for GitHub Actions: Retire Your Stored Access Tokens โ the same short-lived-credential pattern applied to Docker Hub logins from CI.
- GitHub Actions Checkout v7: Fixing the Pwn Request Vulnerability Before July 16 โ another recent GitHub CI/CD security hardening change.
- GitHub Actions Now Holds Suspicious Workflow Runs Before They Touch Your Secrets โ GitHub's broader push on supply-chain security for Actions.
- Official docs: Refreshing user access tokens
- Official changelog: Multiple redirect URIs and token refresh for OAuth apps