OAuth Gone Wrong: The Hidden Token Issue That Brought Down Our Login System
A reused expired refresh token caused widespread login failures in our Node.js app. This article shows how it happened, how we fixed it, and how to avoid it.
Join the DZone community and get the full member experience.
Join For FreeImagine deploying a Node.js/TypeScript backend for user authentication that works flawlessly in development, only to watch users get mysteriously logged out or unable to log in shortly after launching to production. Everything ran fine on your local machine, but in the live environment, users start losing their sessions en masse. Requests to protected endpoints begin failing with “Unauthorized” errors. Panic sets in as your login system, the gatekeeper of your application, is effectively brought down by an invisible foe.
In our case, the culprit was a hidden OAuth token issue involving how we handled refresh tokens. A tiny mistake in token management, something that went unnoticed during development, led to a chain reaction of authentication failures in production.
In this article, we’ll walk through the scenario step by step: how the problem manifested, how we diagnosed the root cause, the code that was to blame, and the fixes and best practices that emerged from the post-mortem. Along the way, we’ll also highlight other common token pitfalls that can similarly wreak havoc if left unchecked.
Symptoms: When Tokens Turn Against You
The first sign of trouble was users being unexpectedly logged out and unable to maintain sessions. Shortly after deploying our authentication system, we noticed patterns like:
- Mass logouts: Users who had logged in earlier suddenly found themselves kicked out and prompted to log in again. This wasn’t a one-off fluke; it was happening to many users around the same time.
- Failed API calls: Our frontend started receiving HTTP 401 “Unauthorized” responses for API requests that require authentication. Essentially, our Node/Express backend was rejecting requests because it considered the access tokens invalid.
- Error logs (when available): Digging into logs revealed error messages indicating invalid or expired tokens. For example, one error from our OAuth provider read:
invalid_grant: Unknown or invalid refresh tokena cryptic message suggesting something was wrong with how we were using refresh tokens. In other instances, our server’s JWT validation threw “invalid_token” errors, complaining that the token’s audience was incorrect or that the token had expired.
One representative log snippet from Auth0’s response during this fiasco was:
HTTP 403: {
"error": "invalid_grant",
"error_description": "Unknown or invalid refresh token."
}
This error essentially means the refresh token we provided to Auth0 was not accepted; it was either expired, already used, or otherwise unrecognized. At first glance, we weren’t sure why this would happen. Refresh tokens are supposed to allow us to get new access tokens when old ones expire. Why would Auth0 consider our refresh token “invalid” all of a sudden?
The impact of this issue was severe. Since our app relied on OAuth tokens for authentication, a widespread token failure meant no one could log in or stay logged in. In effect, our login system was down for all users until the issue was resolved. Next, we’ll see how we investigated the cause of this token havoc.
Reproducing and Observing the Failure
In a staging environment, we tried to reproduce the sequence of events. We had users log in, then simulated the passage of time or conditions until their access tokens would expire. In our setup, an access token (AT) is valid for 24 hours; after that, a refresh token (RT) should be used to obtain a new AT. We watched what happened during that refresh process. Sure enough, when an access token expired, and our system attempted to use the refresh token, the operation failed. Our logs captured the same error as before: “invalid refresh token.”
Interestingly, the first time an access token expired for a user, our system was able to refresh it successfully. The user would get a new access token without noticing any issue. However, the second time an access token needed refreshing (for the same user), it would fail. This was a crucial clue. It hinted that something went wrong after the first refresh cycle.
Checking Token Storage and Flow
We audited how our application was storing and updating tokens:
- Initial login: When a user logs in, Auth0 redirects back to our app with an authorization code, which our Node backend exchanges for tokens. We stored the
refresh_tokenassociated with the user, and we kept theaccess_tokenin memory or sent it to the client. - Using access tokens: The
access_tokenwas used to authorize API calls. This worked fine until expiration. - Refresh logic: We had logic to detect an expired access token and trigger a refresh. This would call Auth0’s
/oauth/tokenendpoint with the stored refresh token to get a newaccess_token. Importantly, Auth0 is configured by default to rotate refresh tokens, meaning every time you use a refresh token to get new credentials, Auth0 will return a new refresh token and invalidate the old one.
We suspected the bug was in how we handled that new refresh token. If we weren’t properly saving the new refresh token, our system would unknowingly continue using the old one, which Auth0 had invalidated. This would perfectly explain why the first refresh works but the second refresh fails.
To confirm, we examined database records and memory caches. Indeed, after a token refresh, the stored refresh token value was not getting updated. The code responsible for the refresh was not replacing the old token with the new one provided by Auth0. In other words, we had a stale refresh token on our hands.
Understanding Refresh Token Rotation
At this point, it’s helpful to explain refresh token rotation, a security feature offered by OAuth providers like Auth0. With rotation enabled, every time you use a refresh token, the authorization server will issue a new refresh token along with the new access token and invalidate the previous refresh token. This prevents reuse; if an attacker somehow steals a refresh token that was used, they cannot use it because it’s no longer valid. Auth0 enables this by default for Single Page Apps, and it’s a recommended practice for security.
The Auth0 community forums have Q&A threads that mirror our situation. One user described getting “invalid_grant: Unknown or invalid refresh token” errors, and an Auth0 engineer replied that using rotating refresh tokens and trying to reuse them would cause exactly that error, “that would invalidate the whole chain”. The original poster later confirmed, “You were right, I was reusing a refresh token that had already been used. That’s what had been causing the error”. This is a spot-on description of our hidden bug.
The Token Mishandling Code (and How It Failed)
Let’s look at a simplified version of the code that led to this issue. Our stack is Node.js with Express, using TypeScript.
// Pseudocode for initial login token exchange (simplified)
import axios from 'axios';
interface TokenResponse {
access_token: string;
refresh_token: string;
expires_in: number;
token_type: string;
}
// Called after user logs in via Auth0 and we have an auth `code`
async function handleAuthCallback(code: string, userId: string) {
const tokenRes = await axios.post<TokenResponse>('https://YOUR_DOMAIN.auth0.com/oauth/token', {
grant_type: 'authorization_code',
client_id: AUTH0_CLIENT_ID,
client_secret: AUTH0_CLIENT_SECRET,
code: code,
redirect_uri: CALLBACK_URL
});
const tokens = tokenRes.data;
saveTokensToDB(userId, {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: Date.now() + tokens.expires_in * 1000
});
// At this point, refreshToken is stored (RT1 for this user)
}
When a user logs in, we exchange the auth code for an access token and a refresh token. We then store the refresh token along with the access token and its expiry time.
Now, here’s the problematic refresh logic we had:
// Pseudocode for refreshing an expired access token
async function refreshAccessToken(userId: string) {
const { refreshToken: storedRT } = getTokensFromDB(userId);
const res = await axios.post<TokenResponse>('https://YOUR_DOMAIN.auth0.com/oauth/token', {
grant_type: 'refresh_token',
client_id: AUTH0_CLIENT_ID,
client_secret: AUTH0_CLIENT_SECRET,
refresh_token: storedRT // using the stored refresh token
});
const newTokens = res.data;
// BUG: We obtain a new refresh token, but we ignore it!
updateTokensInDB(userId, {
accessToken: newTokens.access_token,
// mistakenly reusing the old refresh token:
refreshToken: storedRT,
expiresAt: Date.now() + newTokens.expires_in * 1000
});
}
Look closely at the bug after calling the token endpoint with the old refresh token, Auth0 will return a response containing a new refresh_token alongside the new access_token. We failed to update our stored refresh token to this new RT2. Instead, due to a logical mistake (or omission), our code kept the refreshToken field as storedRT. This means we essentially threw away RT2 and continued to store/use RT1.
Right after this function runs, the user’s session has a fresh access token (AT2) and continues happily... until AT2 expires. When that happens, our refreshAccessToken will run again, but storedRT is still the old RT1. We send RT1 to Auth0’s /oauth/token endpoint:
- Auth0 sees RT1 being used again. Since RT1 was already used before, Auth0 treats this as a token reuse attack. It rejects the request. Furthermore, because RT1 was already invalid, Auth0 knows something’s fishy and also invalidates RT2 as a safety measure.
- Auth0 responds with
invalid_grant, as observed. - Our code doesn’t get a new token, and typically we’d handle this by forcing the user to log in again. In our case, users just got logged out suddenly because the app couldn’t refresh their session.
This bug hid in plain sight because, during initial testing, we never exercised multiple successive token refreshes. On first refresh, everything seemed fine. The hidden problem only surfaced on the next cycle.
How We Fixed It (Correct Token Management)
The fix for this issue was straightforward once identified: always store the latest refresh token provided by the OAuth server. We modified our refresh logic to correctly save the new refresh token:
// Corrected refresh logic
async function refreshAccessToken(userId: string) {
const { refreshToken: storedRT } = getTokensFromDB(userId);
const res = await axios.post<TokenResponse>('https://YOUR_DOMAIN.auth0.com/oauth/token', {
grant_type: 'refresh_token',
client_id: AUTH0_CLIENT_ID,
client_secret: AUTH0_CLIENT_SECRET,
refresh_token: storedRT
});
const newTokens = res.data;
// FIX: Update stored refresh token to the new one from response
updateTokensInDB(userId, {
accessToken: newTokens.access_token,
refreshToken: newTokens.refresh_token || storedRT, // use new RT if present
expiresAt: Date.now() + newTokens.expires_in * 1000
});
}
Important: If you’re implementing refresh token rotation, also be mindful of refresh token absolute expiration. Many providers set a maximum lifetime for a refresh token or token family. For instance, Auth0’s default is 30 days for rotating refresh tokens after 30 days of continuous use; the refresh token will expire, and you’ll need the user to log in again. In our case, we hadn’t hit that limit yet, but it’s something to plan for.
Preventing Similar Token Issues: Best Practices
- Always handle new tokens: If your OAuth provider rotates refresh tokens, your code must capture and store the new token on every refresh. Don’t assume the refresh token stays constant. Read the docs for your provider about token rotation or reuse detection.
- Use TypeScript and linters: This bug was a logical oversight, something TypeScript’s type system might not directly catch, but we improved our types around token responses to make it clear when a new
refresh_tokenis optional or present. We also enabled stricter lint rules. Tools like ESLint and even unit tests can be written to ensure that when a new refresh token is provided, it’s handled. - Graceful error handling: In the event a refresh token is invalid, handle it gracefully. After our fix, an “invalid refresh token” error should be rare, but if it happens, we choose to proactively log the user out and redirect them to login.
authError$.pipe(
filter(err => err.error === 'login_required' || err.error === 'invalid_grant'),
tap(() => {
// Our logic to force a fresh login
logoutUserAndRedirectToLogin();
})
).subscribe();
The idea is to catch scenarios where the auth server signals that the refresh token is no good and respond by starting a fresh auth flow.
- Monitor and log: Implement logging around token refresh operations. In production, an unexpected token refresh failure should be logged with enough detail to debug. For instance, log the error code/message from the provider. This is how we quickly spotted the
invalid_grantmessage and traced it. Over time, monitoring these logs can also help detect if refresh tokens are nearing expiry. - Simulate expiry in testing: One reason this bug slipped by is that in development, we rarely waited long enough to naturally expire tokens and test multiple refresh cycles. To prevent such surprises, we now simulate shorter token lifespans in staging tests.
- Secure storage: Ensure you store tokens securely. In Node backends, prefer storing them server-side rather than exposing them to the client. If this is a browser-based app scenario, use HttpOnly cookies or other secure storage mechanisms to prevent XSS attacks from stealing tokens. The dual-token approach is powerful, but only if the refresh token is well protected. Never commit tokens to logs or error messages.
- Account for clock skew: Another “hidden” token issue to be aware of is clock skew between systems. If your servers have out-of-sync clocks, you might find tokens being rejected as “expired” or “not yet valid” when they should be fine. Many JWT validation libraries allow a few minutes of leeway to account for this. In fact, during our investigation, we came across a case where a JWT was immediately rejected with an error about the
expclaim being too far in the future, a classic sign of clock skew. The fix is to synchronize system clocks and configure a small clockSkew allowance in token validation if needed. - Validate audience/issuer: Misconfiguration of the token audience or issuer is a common OAuth pitfall that can break your login system. If your resource server is expecting a token for audience “X” but your client is requesting a token for audience “Y,” the issued token might be deemed invalid by the API. This often leads to 401 errors saying “The audience is invalid”. To avoid this, double-check that your OAuth client config is requesting the correct audience and your API’s JWT validation is using the correct issuer and audience values. This configuration issue can be subtle if you have multiple environments or multiple APIs.
- Token reuse and concurrency: Another edge case with refresh tokens is handling concurrent requests to refresh. If your app might accidentally try to refresh the token twice in parallel, you could end up invalidating the new token because the second request is using the now-invalidated first token. To guard against this, ensure that your refresh logic is serialized per session only one refresh operation occurs at a time for a given user. You might use a locking mechanism or design the frontend to avoid parallel refresh attempts. Some auth libraries handle this for you by queueing token requests.
- Use official SDKs if possible: To minimize DIY mistakes, consider using official libraries/SDKs. These can abstract the refresh logic or at least provide guidance on best practices. That said, even with SDKs, understanding what’s happening under the hood is important.
Other OAuth Token Pitfalls to Watch Out For
While our story centered on a refresh token rotation issue, it’s worth noting a few other token-related issues that can silently break authentication if you’re not careful:
- Expired refresh tokens – If you assume refresh tokens live forever, think again. Many providers enforce an absolute expiry or an inactivity window. A rotating refresh token in Auth0 by default expires after 30 days, regardless of use. If your app doesn’t anticipate this, users might be fine for a month, and then suddenly everyone is logged out once that limit hits. Make sure to surface such conditions rather than treating them as an error.
- Wrong token scope/audience – If the token doesn’t include the correct scopes or audience for the resource you’re calling, you’ll get 403/401 errors. This can happen if, say, you forget to request the
openid profile emailscopes for an OpenID Connect flow, or you don’t set the audience in Auth0. Always request only the scopes you need, but ensure they cover what the API expects. - Improper token storage on client – Storing tokens in insecure ways can lead to breaches, which may not crash your app but will compromise security. A common best practice is to store access tokens in memory and refresh tokens in HttpOnly cookies, or not store refresh tokens in the browser at all for SPAs, and instead use rotating tokens as we discussed. If a malicious script obtains a refresh token, it could keep your app session alive indefinitely. Use secure cookies or platform-specific secure storage for mobile.
- Not handling logout/revocation – If a user manually logs out, you should consider revoking their refresh token so it can’t be used again. In our system, we implemented a logout to clear server-side sessions and also call the Auth0 revocation API for the refresh token. This ensures that if somehow the token leaks or the user just wants to fully end sessions, it’s honored. Not strictly a “hidden issue,” but forgetting to do this can cause confusion.
Conclusion
OAuth is powerful, but with power comes footguns. Our Node.js login system went down because of a tiny refresh token bug, something that was easy to overlook but had catastrophic consequences in production. The hidden token issue was simply that we weren’t updating a stored token value. Once identified, the fix was a one-liner change, yet it brought to light the importance of thoroughly understanding your authentication provider’s expectations.
In the aftermath, we reinforced our processes simulate real-world token lifecycles in testing, added robust logging around authentication flows, and kept an eye on provider documentation for changes or features like refresh token rotation, leeway for clock skew, etc. By sharing this story, we hope to spare others that moment of dread when you realize your login system, the front door to your app, has unexpectedly locked out your users.
In the end, our system is now stable and more secure. We treat tokens with greater care, always catching and storing new ones, guarding them like the keys to the kingdom that they are, and never assuming something as critical as authentication will “just work” without thorough validation. OAuth got the best of us once, but with these lessons learned, we’re determined not to let a sneaky token issue slip by again. Happy (and safe) coding!
Opinions expressed by DZone contributors are their own.
Comments