
The OAuth refresh that quietly bricks the connection
If you have wired up enough third-party integrations, you have met this bug, or you are going to. It is small, it is one line, and it permanently kills a connection in a way that does not show up until much later. The everyday version: the provider hands back a fresh visitor pass but expects you to keep your existing master key, and code that throws the master key away locks the user out for good.
The assumption that bites#
You store an access token (short-lived) and a refresh token (long-lived) for a user’s connected account. When the access token expires you call the provider’s refresh endpoint, get a fresh access token back, and save it. Naturally, you save the whole response.
That is the bug. Several providers, including some very common ones, frequently return a new access token with no refresh token on a refresh. It is not an error; it is normal, and it means “keep using the refresh token you already have.” But code that blindly saves the response overwrites the stored refresh token with nothing. Now the next refresh has no refresh token, so it fails, and the connection is permanently bricked. The user did nothing, saw nothing, and their integration is just dead until they reconnect from scratch.
The fix is almost insultingly small:
refreshToken: response.refresh_token || existingRefreshTokenKeep the token you have when the provider does not give you a new one. One ||
between a working integration and a dead one.
Never blindly persist a token-refresh response. A refresh that omits the refresh token is telling you to keep the old one, not to throw it away.
While you are in there, refresh lazily#
The other half of getting this right is when you refresh. The tidy-sounding approach is a scheduled job that refreshes tokens ahead of expiry. The simpler and more robust approach is lazy: at the point of use, check whether the token has expired, and only then refresh it. No scheduler to drift, no refreshing tokens for accounts nobody is using, and the refresh happens exactly when a real call needs it.
Two details make the lazy version solid. Write the rotated token back atomically, so two near-simultaneous calls do not both refresh off the same stale token and race. And separate your failure states: “expired and there is no refresh token” is a different, diagnosable problem from “there are no credentials here at all.” A generic 401 tells you nothing; a connection that can say exactly why it is dead is a connection you can fix.
The general shape#
This is a specific instance of a habit worth generalising: be careful what you persist from an external response, because you are encoding assumptions about a contract you do not control. The provider is allowed to omit fields, reuse tokens, and change what a refresh returns. Store defensively, keep what you already have unless you are explicitly given a replacement, and make every dead state say why it died. Integrations do not usually fail loudly. They fail quietly, one bricked token at a time, and the person who finds out is the user.


