GitHub OAuth apps can now register multiple callback URLs and use refreshable user access tokens. Multiple callbacks solve a real deployment problem: one OAuth app can support production, staging, regional domains, or separate web and desktop entry points. They also enlarge the part of the login flow that must be reviewed carefully.

The safe migration is not to paste every environment URL into the settings page. Register a small allowlist, send an exact redirect_uri on authorization and token exchange, bind the response to the browser session with state, and keep development callbacks out of the production app.

Decide whether one OAuth app should serve every environment

Multiple redirect URIs are useful when the environments share ownership, security policy, consent wording, and incident response. They are less useful when a staging system is broadly accessible, uses weaker secrets, or is controlled by a different team.

Use separate GitHub OAuth apps when isolation matters. A production client secret should not be copied into developer laptops or preview deployments. Separate apps also prevent a compromised lower environment from becoming an approved callback destination for production users.

A reasonable production allowlist might contain:

https://gethired.dev/auth/github/callback
https://www.gethired.dev/auth/github/callback
https://accounts.gethired.dev/oauth/github/callback

Do not add wildcard domains, URL shorteners, user-controlled subdomains, or arbitrary preview URLs. A callback is part of the credential-delivery path, not a convenience link.

Send the same exact redirect URI twice

The authorization request should include the callback selected by trusted server configuration:

const callback = process.env.GITHUB_OAUTH_CALLBACK;
const state = crypto.randomBytes(32).toString('base64url');

await sessionStore.set(`oauth:${state}`, {
  callback,
  returnTo: '/settings/connections',
}, { ttlSeconds: 600 });

const authorize = new URL('https://github.com/login/oauth/authorize');
authorize.searchParams.set('client_id', process.env.GITHUB_CLIENT_ID);
authorize.searchParams.set('redirect_uri', callback);
authorize.searchParams.set('scope', 'read:user user:email');
authorize.searchParams.set('state', state);

When exchanging the code, send the same callback value stored with that state. Do not rebuild it from the incoming Host, Origin, or forwarded headers. Those values can be wrong behind proxies and may be attacker-controlled when the proxy trust configuration is loose.

const pending = await sessionStore.take(`oauth:${req.query.state}`);
if (!pending) throw new Error('OAuth state is missing or expired');

const response = await fetch('https://github.com/login/oauth/access_token', {
  method: 'POST',
  headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
  body: JSON.stringify({
    client_id: process.env.GITHUB_CLIENT_ID,
    client_secret: process.env.GITHUB_CLIENT_SECRET,
    code: req.query.code,
    redirect_uri: pending.callback,
  }),
});

An exact callback makes the intended destination explicit at both steps and avoids accidental fallback behavior.

Treat state as a one-time server-side record

state protects the login flow from request forgery and login confusion only when it is unpredictable, expires quickly, and is checked once. Storing the complete application state in an unsigned query string is not equivalent.

Keep the return path server-side and restrict it to a local path. A value such as https://attacker.example must never become the post-login destination merely because it arrived as ?returnTo=.

Consume the record atomically. If two callback requests reuse the same state, only the first should proceed. Record failures without logging the authorization code, access token, refresh token, or client secret.

Add refresh tokens without creating a permanent secret leak

GitHub’s updated OAuth flow can issue refreshable user access tokens. Shorter-lived access tokens reduce the lifetime of a stolen bearer token, but the refresh token becomes a sensitive long-lived credential.

Encrypt refresh tokens at rest with a managed key, restrict database access, and never return them to browser JavaScript. Store token metadata such as GitHub user ID, scopes, expiry, last refresh time, and revocation state separately from application session cookies.

Refresh slightly before expiry and serialize concurrent refresh attempts for one account. Without coordination, two requests can race: both use the same refresh token, one succeeds, and the other overwrites the valid replacement with an error or stale value.

lock: oauth-refresh:{githubUserId}
read encrypted token
refresh once
store replacement atomically
release lock

On invalid_grant, stop retrying, remove the unusable credential, and ask the user to reconnect. Endless refresh retries create noise and can hide an actual revocation.

Test callback confusion and proxy behavior

Before enabling the new URLs, test expected and hostile cases:

  • each registered callback completes successfully;
  • an unregistered callback is rejected;
  • a callback with a different scheme, port, path, case, or trailing slash does not silently match;
  • expired and reused state values fail;
  • a tampered return path stays inside the application;
  • forwarded-host changes cannot alter the token-exchange callback;
  • logs and analytics contain no codes or tokens;
  • revoking access produces a clear reconnect flow.

Test behind the same CDN and reverse proxy used in production. Authentication bugs often appear only when TLS terminates upstream or the application receives a rewritten host.

Roll out without interrupting existing users

Add the new callback before deploying code that uses it. Keep the old callback registered until traffic and logs show the migration is complete. Deploy to one environment, complete several fresh authorizations, refresh a token, revoke one authorization, and verify account linking uses GitHub’s stable user ID instead of mutable username or email.

Track callback failures by an internal reason code, never by recording secrets. Useful reasons include state_missing, state_expired, callback_mismatch, exchange_rejected, and refresh_revoked.

After the migration window, remove callbacks that are no longer used. Every approved URL should have an owner and a real deployment behind it.

Primary references

Multiple callbacks make deployment cleaner when the trust boundaries genuinely belong together. Keep the list short, derive callbacks from trusted configuration, validate one-time state, and treat refresh tokens as production secrets.