All articles
Dev ToolsBy Petru Popa · 6 min read

Your Refresh Token Is Now a Lock, Not a Secret

GitHub gave OAuth apps ten redirect URIs and eight-hour tokens. The docs say the second one changes how many processes may hold your credential.

Nothing went up here on 16 August, so this covers the stretch since the 15th. The item worth your time in that window is dated the 14th, and it has been mis-filed as a convenience feature.

What shipped

GitHub changed two things about OAuth apps in one changelog entry on 14 August. An OAuth app can now register up to ten redirect URIs rather than one, so one registration serves several environments. And OAuth apps got short-lived tokens: the entry puts the access token at eight hours and the refresh token at six months, reachable through the offline_access scope or forced in the app registration settings, and switched on by default for all new applications. Existing apps may turn it off if their SDK cannot refresh.

The entry says these land in GitHub Enterprise Server 3.23. The 3.22 release candidate was announced three days earlier, on 11 August, and mentions none of it.

The redirect URI half reads like the headline. The token half is the one that changes how you build.

What the docs say and the entry does not

The changelog says refresh exists. The docs say what refresh does to the credential you already hold, which is a different fact.

The OAuth apps authorization page states that the access token expires after eight hours and the refresh token expires after six months without use — a decay clock, not a calendar one. Then the sentence that carries the whole change: once you use a refresh token, that refresh token and the old access token stop working. The GitHub Apps page on refreshing user access tokens states that invalidation in the same terms. The call is a POST with grant_type set to refresh_token, and the response carries expires_in and refresh_token_expires_in as seconds from now.

So refresh is not a top-up. It is a swap that destroys both halves of what you held.

That is refresh token rotation, and it is what the specification asks for. RFC 9700, published January 2025 as BCP 240, says in Section 4.14 that refresh tokens issued to public clients must be sender-constrained or use rotation. GitHub chose rotation, correctly.

The part that breaks

A non-expiring token is a secret, and secrets copy. Put one in an environment variable, hand it to four replicas and three CI jobs, and every copy behaves identically forever.

A rotating refresh token is a lock. It has one valid holder, and spending it moves the holder. When two processes share one refresh token and both notice the access token has expired, both refresh. One wins. The other presents a token that has already been spent and gets nothing — and the access token it was holding is dead too, because the winner's refresh killed it.

That failure has a schedule. It cannot fire more often than the access token expires, so an eight-hour lifetime gives an integration that has run untouched for a year about three chances a day to break. It will not reproduce on a laptop running one process.

RFC 9700 makes the bug worse at the protocol layer than it looks at the application layer. Section 4.14 tells authorization servers to detect replay of an already-used refresh token and, on detection, to revoke every token issued to that client. Under that guidance a concurrency bug in your own deployment is indistinguishable from a theft. Neither GitHub docs page I read describes revoking the grant on reuse; both stop at the reused refresh token and the old access token ceasing to work. So this is not a claim that GitHub does it — only that it is what the specification GitHub is following recommends, and that you want to know which you get before you learn it at three in the morning.

This is the shape of cost that the line items a proof of concept skips keeps landing on. Nothing about writing the integration got harder. The thing that changed only exists once there is more than one of you.

An ordering rule in the other half

The authorization docs note that redirect_uri is optional, and that when it is omitted GitHub sends the user to the first callback URL configured in the app settings. With one URL that sentence is inert. With ten it is a rule about list order, on a settings screen that gives no sign order means anything.

The wildcard rule also has boundaries the entry's summary omits. With matching on, the redirect host excluding subdomains and the port must match the callback exactly, and the path must reference a subdirectory of it. With matching off, the redirect must match exactly. The docs supply a date the changelog does not: apps created before 3 August 2026 have wildcard matching on by default. The entry describes the new toggle as making that behavior visible and controllable, which is the tell: it was already on for every app older than that date.

What to do today

Find out whether you have one holder. Take a refresh token you can burn and spend it twice:

curl -s -X POST https://github.com/login/oauth/access_token \
  -H 'Accept: application/json' \
  -d client_id=$CLIENT_ID -d client_secret=$CLIENT_SECRET \
  -d grant_type=refresh_token -d refresh_token=$RT

Run it twice with the same $RT. If the second call returns a token pair, there is a grace window and a shared token survives a race. If it does not, refresh is strictly single-use, and every process sharing that token needs to stop — one refresher, writing the result somewhere the others read.

Then count. The number that matters is not ten URIs or eight hours. It is how many concurrent processes hold your OAuth token right now. At one, this costs an afternoon of SDK work. Above one, you have a distributed lock to build that nobody put on the roadmap.

Verdict

Take the redirect URIs immediately and separately; that half is free. On token expiry, new apps have no decision to make, because it is already on. For existing apps, do not opt in until the count above comes back as one. Enterprise Server shops have the most room: 3.22 was only at release candidate on 11 August and this arrives in 3.23, so spend the wait on the audit rather than the upgrade.

The claim here that could be wrong is that the token lifetime, not the redirect URIs, is the expensive half. The second curl returning a valid token pair would show it — an idempotent refresh window makes the shared-token race harmless and reduces all of this to an SDK bump.

Ready to start?

Turn this into a plan for your team.

One week, fixed fee: a working session with your team, a prioritized use-case backlog, and an ROI model for the opportunities worth chasing.

Book an AI Opportunity Sprint