DEV Community

Angel Rose
Angel Rose

Posted on

React Native Authentication in 2026: The Refresh-Token Pattern That Actually Scales

TL;DR

  • Access token in memory, refresh token in expo-secure-store. AsyncStorage is plaintext on disk. Stop using it for credentials.
  • Access token: 15 minutes. Refresh token: 30 days. Different signing secrets. The split is about blast radius.
  • One singleton refreshPromise stops five concurrent 401s from firing five refresh calls (four of which will fail once rotation kicks in).
  • Always pass an explicit keychainService. Omit it and users get logged out after every TestFlight update, iOS only.
  • Total cost: roughly 200 lines of app code, no managed auth vendor required.

Most React Native auth tutorials show you how to log a user in. Almost none of them show you how the app behaves at 3 AM when five in-flight requests all hit an expired token at the same time, or how your carefully-stored token silently disappears after a TestFlight update. This post is the pattern that survived three production apps.

Why most tutorials fail

You've seen this pattern a hundred times:

const token = await AsyncStorage.getItem('access_token');
fetch(url, { headers: { Authorization: `Bearer ${token}` } });
Enter fullscreen mode Exit fullscreen mode

Three problems compound in production:

  1. AsyncStorage is not secure. It's plaintext on disk. Any process that can read your app's sandbox can read the token.
  2. No refresh handling. The token expires, users get 401s, and your fix is "log them out and back in." That's a leaky bucket.
  3. Race conditions on refresh. When multiple requests fire while the token is expiring, they all try to refresh at once and only one wins.

The pattern below solves all three.

The two-token model

Issue two tokens from your API on login:

  • Access token: short-lived (15 minutes), signed with ACCESS_SECRET. Sent with every authenticated request.
  • Refresh token: long-lived (30 days), signed with a different REFRESH_SECRET. Used only to mint new access tokens.

The point of the split is blast radius. If your access token leaks (network sniffer, log line), the attacker has 15 minutes. The refresh token never leaves the keychain.

Where to store each token

The rule that took me two apps to internalize:

  • Access token → in-memory only. A module-level variable. It dies with the JS bundle. That's fine. The app will refresh from disk if needed.
  • Refresh token → expo-secure-store. Backed by iOS Keychain / Android Keystore. Never AsyncStorage.
// tokens.js
import * as SecureStore from 'expo-secure-store';

let accessToken = null;

export const getAccessToken = () => accessToken;
export const setAccessToken = (t) => { accessToken = t; };

export const getRefreshToken = () =>
  SecureStore.getItemAsync('refresh_token', {
    keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
    keychainService: 'com.yourcompany.yourapp.auth',
  });

export const setRefreshToken = (t) =>
  SecureStore.setItemAsync('refresh_token', t, {
    keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
    keychainService: 'com.yourcompany.yourapp.auth',
  });
Enter fullscreen mode Exit fullscreen mode

The two options on SecureStore matter:

  • WHEN_UNLOCKED_THIS_DEVICE_ONLY: the token is gone if the device is restored onto a new device. Prevents session cloning.
  • Explicit keychainService: survives TestFlight builds (more on this below).

The refresh middleware pattern

Wrap every authenticated fetch in a middleware that handles 401s transparently:

// authFetch.js
import {
  getAccessToken,
  setAccessToken,
  getRefreshToken,
  setRefreshToken,
} from './tokens';

const API = 'https://your-api.com';
let refreshPromise = null;

async function performRefresh() {
  const rt = await getRefreshToken();
  if (!rt) throw new Error('NO_REFRESH_TOKEN');

  const r = await fetch(`${API}/auth/refresh`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ refresh_token: rt }),
  });

  if (!r.ok) {
    // Refresh token invalid, user must log in again
    await setRefreshToken('');
    setAccessToken(null);
    throw new Error('REFRESH_FAILED');
  }

  const { access_token, refresh_token } = await r.json();
  setAccessToken(access_token);
  await setRefreshToken(refresh_token); // rotation: new RT every refresh
  return access_token;
}

export async function authFetch(url, opts = {}) {
  const doRequest = async () => {
    const headers = {
      ...(opts.headers || {}),
      Authorization: `Bearer ${getAccessToken()}`,
    };
    return fetch(url, { ...opts, headers });
  };

  let response = await doRequest();

  if (response.status === 401) {
    if (!refreshPromise) {
      refreshPromise = performRefresh().finally(() => {
        refreshPromise = null;
      });
    }
    await refreshPromise; // all in-flight 401'd requests await the same refresh
    response = await doRequest();
  }

  return response;
}
Enter fullscreen mode Exit fullscreen mode

The singleton refreshPromise is the piece most tutorials miss. Without it, five concurrent requests hitting an expired token cause five refresh calls. Four of them will 401, because the refresh-token rotation on the first call already invalidated the shared refresh token.

TestFlight and iOS keychain: the accessGroup trap

Here's a bug I've watched three teams hit:

"Users are getting logged out after every TestFlight update, but only on iOS."

The cause: iOS keychain items are scoped by accessGroup. When TestFlight installs a new build, it may preserve keychain access, but only if the keychainService string is consistent across builds. If your code omits keychainService, expo-secure-store generates a random-ish default that changes across builds.

Fix: always pass an explicit keychainService (as in the tokens.js above). Once set, it survives TestFlight promotions, production releases, and version bumps.

While you're at it, add a startup check that re-validates the token against your API:

export async function bootstrapAuth() {
  if (!(await getRefreshToken())) return { authenticated: false };

  try {
    // Try any authenticated endpoint that returns quickly
    const r = await authFetch(`${API}/me`);
    if (r.ok) return { authenticated: true, user: await r.json() };
    return { authenticated: false };
  } catch {
    return { authenticated: false };
  }
}
Enter fullscreen mode Exit fullscreen mode

This way a truly-invalidated session (user was banned server-side, refresh token was revoked) doesn't sit stale in the app UI.

Anti-patterns

Stop doing these:

  • Storing tokens in AsyncStorage. It's not encrypted. On Android it's SharedPreferences XML, readable with adb.
  • Bearer tokens in URL query strings. They end up in server logs, analytics captures, and browser history.
  • A single fetch wrapper per API call. You'll forget one, and that endpoint will silently break auth. Wrap it once at the module level.
  • Manual logout that only clears the access token. If the refresh token is still in the keychain, the next bootstrapAuth() re-authenticates the user.
  • Refresh-token rotation without server-side invalidation. If your API doesn't invalidate the old refresh token on rotation, an attacker who steals it can refresh indefinitely.

What to ship first

If you're starting from a fresh React Native app:

  1. Set up the two-token model in your API. djangorestframework-simplejwt, or jose for Node, both handle rotation out of the box.
  2. Copy the tokens.js and authFetch.js above.
  3. Call bootstrapAuth() in your root component's useEffect(() => {}, []).
  4. Add a global 401 → redirect-to-login handler for the terminal case (refresh token invalid).

That's roughly 200 lines of app code. It'll outlive whichever managed auth service you were considering, and the security posture is stricter than most of them. If you'd rather not wire it by hand, RapidNative scaffolds Expo apps with this pattern preconfigured, though the pattern itself is straightforward enough that most teams should own it.

Related patterns worth reading

  • Biometric-gated refresh: use expo-local-authentication to require Face ID before the refresh call. Great for financial and health apps.
  • Silent token rotation on app-foreground: refresh preemptively when the app comes back from background. Catches stale-token edge cases.
  • Encrypted-at-rest offline queue: for apps that need to work offline and sync later, queue requests in encrypted storage until the token refreshes.

Auth done right is invisible. Ship it once, ship it well, and don't touch it again.

What's your current setup? Drop a comment with how you're handling refresh in production, especially if you've found a cleaner way to dedupe concurrent refresh calls.

Top comments (0)