GitHub is adding more options for OAuth apps to use expiring access tokens and refresh tokens. That is a good security direction, but it changes an assumption many small applications make: a token returned during the first login may not work forever.
The migration is not “replace one token column with another.” You need a refresh flow, encrypted storage, concurrency control, clear user recovery, and logs that never expose credentials. The goal is to rotate credentials without making every user reconnect at the same moment.
Understand the two-token model
An access token is the short-lived credential your server sends to GitHub. A refresh token is used to obtain a new access token. Treat both as secrets, but give them separate expiry and rotation handling.
| Credential | Job | Store | When it changes |
|---|---|---|---|
| Access token | Call GitHub APIs | Encrypted at rest | On refresh or expiry |
| Refresh token | Request a new access token | Encrypted at rest | Often during refresh rotation |
| Provider user ID | Identify the account | Normal database column | Usually stable |
Do not use the access token as your application’s user ID. Store GitHub’s stable user identifier and keep provider credentials in a separate record so an authentication change does not corrupt account identity.
Design the database before changing the callback
A useful token record includes the provider, encrypted access token, encrypted refresh token, expiry timestamps, a rotation version, and the last successful refresh time.
create table oauth_credentials (
user_id uuid primary key references users(id),
provider text not null,
access_token_ciphertext text not null,
refresh_token_ciphertext text,
access_expires_at timestamptz,
refresh_expires_at timestamptz,
rotation_version integer not null default 1,
refreshed_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
The exact schema can differ, but plaintext tokens should not appear in database dumps, logs, analytics events, or error messages. Use an application-managed encryption key or a secret-management service, and document who can decrypt credentials in production.
Refresh just before an API call
A request should obtain a usable access token through one shared function. This prevents every GitHub integration from implementing expiry logic differently.
async function githubAccessToken(userId) {
const credentials = await credentialsRepo.findForUpdate(userId);
if (!credentials.accessExpiresAt || credentials.accessExpiresAt > new Date(Date.now() + 60_000)) {
return decrypt(credentials.accessTokenCiphertext);
}
const refreshed = await github.refreshAccessToken({
refreshToken: decrypt(credentials.refreshTokenCiphertext),
});
await credentialsRepo.replaceAfterRefresh(userId, {
accessTokenCiphertext: encrypt(refreshed.access_token),
refreshTokenCiphertext: refreshed.refresh_token
? encrypt(refreshed.refresh_token)
: credentials.refreshTokenCiphertext,
accessExpiresAt: new Date(Date.now() + refreshed.expires_in * 1000),
refreshExpiresAt: refreshed.refresh_token_expires_in
? new Date(Date.now() + refreshed.refresh_token_expires_in * 1000)
: credentials.refreshExpiresAt,
});
return refreshed.access_token;
}
The findForUpdate and replacement operation matter. Two requests arriving together can otherwise both refresh the same credential, and one request may overwrite the newer refresh token with an older value. Use a row lock, short distributed lock, or optimistic version check.
Handle refresh-token rotation safely
Some providers rotate the refresh token as part of a successful refresh. Always persist the returned refresh token when one is supplied. If you keep using the old value, the next refresh can fail even though the current API call succeeded.
Use this sequence:
- Lock the credential record.
- Re-read it after acquiring the lock because another request may have refreshed it.
- Return the new access token if it is now valid.
- Otherwise refresh once and store the complete returned credential set.
- Commit the database update before releasing the lock.
Never retry a refresh indefinitely. A revoked refresh token needs a user-facing reconnect flow, not a busy loop that creates rate-limit noise.
Preserve the application session
The GitHub token is an integration credential, not necessarily the session cookie for your site. Keep the local user session valid while the integration is reconnecting, then show a narrow message such as “Reconnect GitHub to continue importing repositories.” Avoid silently deleting the account or forcing an unrelated password reset.
If the provider reports an invalid or expired refresh token:
- mark the integration as
reauthorization_required; - stop background jobs that call GitHub for that user;
- keep the failure reason generic in the UI;
- offer a new OAuth authorization link with the correct state and redirect URI;
- record an audit event without recording the token.
Test the dangerous cases
Test more than the first successful login. A useful test matrix includes:
| Scenario | Expected result |
|---|---|
| Access token expires | One refresh, then API call succeeds |
| Two requests expire together | One refresh; both requests use the new token |
| Refresh token rotates | New refresh token is persisted |
| Refresh token is revoked | Integration pauses and asks for reconnect |
| GitHub returns 401 after refresh | Retry once, then surface a controlled error |
| User denies reauthorization | Local account remains intact |
Add metrics for refresh success, refresh failure, reauthorization-required users, and GitHub API 401 responses. Do not attach raw authorization URLs or tokens to those events.
Security details that are easy to miss
Use a random, one-time state value for every OAuth start and verify it in the callback. Restrict redirect URIs to exact HTTPS routes in production. Validate the provider user identity returned by GitHub before linking it to an existing account. Keep scopes minimal and remove scopes your feature does not use.
Our GitHub OAuth redirect URI migration guide covers the redirect side. Token rotation belongs beside it because a correct callback can still create an unsafe system if credentials are stored forever or refreshed without concurrency control.
Sources and further reading
- GitHub August 2026 changelog
- GitHub OAuth apps documentation
- GitHub OAuth app management documentation
Final checklist
- Access and refresh tokens are encrypted at rest.
- GitHub’s stable user ID, not a token, identifies the account.
- Refresh logic is centralized and concurrency-safe.
- Rotated refresh tokens replace old values atomically.
- Revoked credentials trigger reconnect instead of infinite retries.
- OAuth state, scopes, redirect URIs, logs, and metrics are reviewed.
Discussion
What would you try, change, or challenge after reading this guide? Specific results and errors help the next reader.
Comments will load as you reach this section.