react-native-auth0 icon indicating copy to clipboard operation
react-native-auth0 copied to clipboard

Users getting logged out with "Unsuccessful Refresh Token exchange, reused refresh token detected"

Open LA-Johan opened this issue 2 months ago • 13 comments

Checklist

Description

Our usage of Auth0 is pretty simple. After the user has been logged in we just attach the access token to network calls and verify them server side.

const useApi = (): Api => {
  const { getCredentials } = useAuth0()
  const backend = axios.create({ baseURL: apiUri, timeout: 10000 })
  backend.interceptors.request.use(
    async config => {
      const credentials = await getCredentials()
      config.headers["Authorization"] = `Bearer ${credentials?.accessToken}`
      return config
    },

    async error => {}
  )
}  

We have gotten user reports that they get error in title. Our understanding is that this error is unrecoverable so we log the user out and force them to re-authenticate. We have a couple of thousand weekly users and this happens to maybe 2% of them.

One log id as an example: 90020251110035154104236000000000000001223372093331920555

Our top level provider:

<Auth0Provider domain={domain} clientId={clientId}>
      <Main />
</Auth0Provider>

Reproduction

Seems to happen randomly, a few times a week for affected users. So far we've only seen it on iOS.

Additional context

A similar issue happens when we use the react version, for both MacOS, iOS, and Windows devices. We allow overlap for expiring refresh tokens (120 seconds). Our refresh token are set to last one year, and we're currently seeing this multiple times a week for users.

react-native-auth0 version

5.1.0

React Native version

0.81.4

Expo version

Not using expo

Platform

iOS

Platform version(s)

Multiple

LA-Johan avatar Nov 10 '25 04:11 LA-Johan

Hi @LA-Johan,

Thank you for reporting this issue. Based on the error message you're seeing, it appears you have Refresh Token Rotation enabled in your Auth0 tenant configuration. With this security feature enabled, refresh tokens are single-use tokens that get invalidated once they're used to obtain new credentials.

We're currently working to reproduce this issue internally so we can better understand the root cause and implement a robust solution directly in the library. We'll keep you updated on progress towards a library-level solution!

Root Cause

Looking at your implementation, I believe the issue stems from concurrent calls to getCredentials(). When multiple API requests are made simultaneously, your interceptor can trigger multiple parallel getCredentials() calls. This can lead to a race condition where:

  1. Request A calls getCredentials() and starts a token refresh
  2. Request B calls getCredentials() at nearly the same time with the same refresh token
  3. Both requests attempt to use the same refresh token
  4. One succeeds and invalidates the token
  5. The other fails with the "reused refresh token detected" error

Recommended Solution

In the meantime, you can implement request deduplication to ensure that only one getCredentials() call is in flight at a time. Here's a refactored version of your interceptor:

const useApi = (): Api => {
  const { getCredentials } = useAuth0();
  const credentialsPromiseRef = useRef<Promise<Credentials | null> | null>(null);
  
  const backend = axios.create({ baseURL: apiUri, timeout: 10000 });
  
  backend.interceptors.request.use(
    async config => {
      // If there's already a getCredentials call in progress, reuse it
      if (!credentialsPromiseRef.current) {
        credentialsPromiseRef.current = getCredentials().finally(() => {
          // Clear the promise once it's resolved/rejected
          credentialsPromiseRef.current = null;
        });
      }
      
      const credentials = await credentialsPromiseRef.current;
      config.headers["Authorization"] = `Bearer ${credentials?.accessToken}`;
      return config;
    },
    async error => {
      return Promise.reject(error);
    }
  );
  
  return backend;
};

⚠️ Important Limitation

Note: This solution only works if you're using a single instance of the useApi hook across your application. If you call useApi() in multiple components, each will create its own credentialsPromiseRef, and concurrent requests from different instances can still trigger the race condition.

For complete protection, you would need to either:

  1. Create the API instance once at a higher level (e.g., Context) and share it across components
  2. Move the credentialsPromiseRef outside the hook to make it shared across all instances

Let us know if this solution works?

subhankarmaiti avatar Nov 10 '25 05:11 subhankarmaiti

@subhankarmaiti thank you for getting back to me so quickly. I remember considering this but I believe previous communication indicated that the library should already handle this. Is that the desired end-goal?

Regarding your proposed solution, I should just be able to have a top-level promise variable instead of a ref to ensure that its globally shared, does that seem correct?

LA-Johan avatar Nov 10 '25 05:11 LA-Johan

Additionally, since we allow for a 120 second overlap period shouldn't that prevent this case?

Update: Answer to this question from our auth0 support engineer:

Leeway time isn’t working because all tokens are created in sequence within the same token family. When a refresh token is used to get a new access token, Auth0 issues a token that is a child of the previous token.

Auth0 tracks these token families to detect suspicious activity. If two refresh tokens from the same family  are used at the same time, Auth0 assumes one might be compromised and invalidates the entire family, which causes the “reused refresh token detected” error you’re seeing.

The leeway period is more of a solution for network delays.

LA-Johan avatar Nov 10 '25 05:11 LA-Johan

This issue has plagued us in different forms for a while now. We have implemented our own throttling (similar to that suggested) to limited success. We have gone a step further and even put a separate caching layer in place, where we read/write the access token very deliberately to our own store, only interacting with Auth0.getCredentails() when required (i.e on app open, when access token is due to expire etc). Yet it still, given enough time, starts showing up in our error logs.

It is very difficult to re-create. I have set forceRefresh: true and run Promise.all([getCredentails x 10 ]) and seen 10 requests come through in the auth0 tenant logs at the same time (within 1 second), and them all be successful!

It seems to me that the system is incapable of recovering from this scenario without the user having to log in again?

  • User has valid refresh and access token
  • getCredentials() goes to network and exchanges the refresh token for a new access token, but it results in an fertft error
  • The refresh token is now spoiled and can no longer be used to exchange for a new access token
  • However hasValidCredentials() will return true, and calling getCredentials() will simply result in another failed exchange and fertft

I have taken this a step further, if in production I get an fertft and hasValidCredentials() = true and I can otherwise reasonably assume that at some point the user was authenticated, then I have attempted a background "silent login" to attempt to fix the session automatically for the user. (i.e the Auth0 Web Session credentials are still valid, the user has not logged out, and if I attempt to silent login with prompt: "none" then it should work given the explanations above). But the success rate of this operation is 1.5%!!

There must be more to it than "avoid making concurrent calls to getCredentials()". Is there any other way to automatically fix the session for the user?

edit: Also the Auth0 logs both in the tenant and via the SDK are not helpful for diagnosing this at all. Some fertft can only be looked up on client_ip, I cannot search for a given user and see all events, some fertft will be missing. And client_ip is not great as they can be shared and belong to geo-blocks of ISPs and so its really hard to say which user exactly may have caused the fertft

tavvy avatar Nov 12 '25 15:11 tavvy

Update from our end: after implementing the throttle and running in production for about a day on web and half a day on mobile the issue seems to have lessened, but not gone away fully. Will keep monitoring. Thank you @tavvy for additional information, good to see we're not alone!

LA-Johan avatar Nov 12 '25 15:11 LA-Johan

A day later we're still seeing this happen on users with the newer version.

LA-Johan avatar Nov 13 '25 17:11 LA-Johan

Hey @LA-Johan, do you have a rough sense of how much the issue improved after adding the throttle? I'm considering implementing the same approach.

TheButchersBoyInsignia avatar Nov 16 '25 23:11 TheButchersBoyInsignia

@TheButchersBoyInsignia with more data I'm now not sure it helped at all unfortunately.

LA-Johan avatar Nov 16 '25 23:11 LA-Johan

@subhankarmaiti anything we can do on our end or logging we can enable to trace this down? The fact that it happens to us on both react and react native seems like a big clue. I believe that one support engineer mentioned these requests happened a minute apart, so it wasn't some immediate concurrency issue.

Leeway time isn’t working because all tokens are created in sequence within the same token family. When a refresh token is used to get a new access token, Auth0 issues a token that is a child of the previous token.

Auth0 tracks these token families to detect suspicious activity. If two refresh tokens from the same family  are used at the same time, Auth0 assumes one might be compromised and invalidates the entire family, which causes the “reused refresh token detected” error you’re seeing.

The leeway period is more of a solution for network delays.

Given that comment from support engineer, what happens if a refresh requests fails on the way back on a round trip? Then it would seem like the client just gets stuck in a bad state automatically?

LA-Johan avatar Nov 17 '25 15:11 LA-Johan

@LA-Johan I've been looking at all our data, and the only charts I can get to line up with the increase in token exchange errors (fertft) is iOS 26.1 adoption. 26.0 usage is higher and does not seem to have had an impact, its specifically 26.1, and it appears to be having an outsized impact (26.1 is roughly 20% of our iOS user base at the moment, yet its having a large impact on fertft). Now it could be other changes in our app code and releases, but I wanted to share in case you (or anyone else) has observed 26.1 issues. I've redacted y axis.

Image

tavvy avatar Nov 19 '25 14:11 tavvy

Hi @tavvy @LA-Johan, Could you please verify if both the hooks-based and class-based approaches are being used in your application? We are only able to reproduce the issue when multiple instances of the credential manager are created, which can happen if both approaches are used simultaneously within the same app.

subhankarmaiti avatar Nov 24 '25 19:11 subhankarmaiti

@subhankarmaiti we are only using the hook based approach. We're also seeing this issue in the web using the react SPA, where we're also only using the hook based approach (with a similar credentialsPromiseRef debounce).

Repeating what I shared before: some of these "old token requests" are coming in a minute + after the previous request according to the support engineer I talked to, that does not seem like a race condition to me.

What happens if?

  • Request A calls getCredentials() and starts a token refresh
  • Request A hits the server and gets a new authentication token
  • Request A fails on the way back, never making it to the client
  • Later, request B tries another call with the same (old) token

LA-Johan avatar Nov 24 '25 20:11 LA-Johan

@subhankarmaiti We use the hook based approach. With out of the box provider etc.

However we do have special edge case we cover. Because we have migrated our application we have had a change in clientId, this means that occasionally upon upgrade we encounter a "clientId's do not match" error upon getCredentials(). If and only if we encounter this error we run the following (simplified) function:

function clearLegacyClientIdCredentials() {
    try {
      const legacyClient = new Auth0({ ...OLD_CLIENT_ID_SETTINGS });
      await legacyCient.clearCredentials()
    } catch (e) {}
}

There was no other way to achieve this in our testing, but I'm open to suggestions. The migration is well past us now, so this function is very very rarely triggered and as you can see the client is only created upon the function invocation.

tavvy avatar Nov 25 '25 09:11 tavvy