Authentication Token Management: Handling Token Refresh and State Reset in React Native

October 17, 2025

Robust token management is more than “call refresh on 401.” You need a lifecycle that protects security, avoids race conditions, keeps UI state consistent, and handles edge cases (network loss, device changes, revoked sessions).

This guide provides provider-agnostic patterns for access/refresh tokens, automatic refresh, state sync, and error recovery.

Authentication Lifecycle

Token types

  • Access token — short-lived, sent with API calls.
  • Refresh token — longer-lived, exchanged for new access tokens.
  • ID token (optional) — user identity claims (e.g., JWT).
  • Device/linking token (optional) — identify device/session, tie into biometrics.

References: OAuth 2.0, OIDC, JWT


Token Service (Provider-Agnostic)

Encapsulate storage + refresh. Never let UI talk to storage directly.

// services/auth/TokenService.js
export class TokenService {
  constructor(
    store, // SecureStore/Keychain
    axios,
    config
  ) {
    this.store = store;
    this.axios = axios;
    this.config = config;
  }

  async getTokens() {
    const [at, rt, exp] = await Promise.all([
      this.store.getItem("accessToken"),
      this.store.getItem("refreshToken"),
      this.store.getItem("expiresAt"),
    ]);
    return {
      accessToken: at,
      refreshToken: rt,
      expiresAt: exp ? Number(exp) : null,
    };
  }

  async setTokens(r) {
    const expiresAt = Date.now() + r.expires_in * 1000;
    await this.store.setItem("accessToken", r.access_token);
    if (r.refresh_token)
      await this.store.setItem("refreshToken", r.refresh_token);
    await this.store.setItem("expiresAt", String(expiresAt));
  }

  async clearTokens() {
    await Promise.all([
      this.store.removeItem("accessToken"),
      this.store.removeItem("refreshToken"),
      this.store.removeItem("expiresAt"),
    ]);
  }

  isExpired(expiresAt, skewMs = 15_000) {
    return !expiresAt || Date.now() + skewMs >= expiresAt;
  }

  // Exchange refresh token for new access token (RFC 6749 §6)
  async refresh() {
    const { refreshToken } = await this.getTokens();
    if (!refreshToken) return null;

    const res = await this.axios.request({
      method: "POST",
      url: this.config.refreshUrl,
      data: {
        grant_type: "refresh_token",
        refresh_token: refreshToken,
        client_id: this.config.clientId,
      },
    });

    const payload = res.data;
    await this.setTokens(payload);
    return payload.access_token;
  }
}

Axios Integration (One Refresh At A Time)

Queue requests during refresh; avoid token storms and loops.

// services/http/AxiosService.js
import axios from "axios";
import { TokenService } from "../auth/TokenService";

export class AxiosService {
  axios;
  isRefreshing = false;
  waiters = [];

  constructor(tokens, baseURL) {
    this.tokens = tokens;
    this.axios = axios.create({ baseURL, timeout: 20_000 });

    this.axios.interceptors.request.use(async (config) => {
      const { accessToken, expiresAt } = await this.tokens.getTokens();
      let token = accessToken;

      if (this.tokens.isExpired(expiresAt)) {
        token = await this.refreshOnce();
      }

      if (token && config.headers)
        config.headers.Authorization = `Bearer ${token}`;
      return config;
    });

    this.axios.interceptors.response.use(
      (r) => r,
      async (error) => {
        const status = error.response?.status ?? 0;
        const original = error.config;

        // On 401, try a single refresh-then-retry path
        if (status === 401 && !original._retry) {
          original._retry = true;
          const token = await this.refreshOnce();
          if (token) {
            original.headers = {
              ...(original.headers || {}),
              Authorization: `Bearer ${token}`,
            };
            return this.axios.request(original);
          }
        }
        return Promise.reject(error);
      }
    );
  }

  async refreshOnce() {
    if (this.isRefreshing) {
      return new Promise((resolve) => this.waiters.push(resolve));
    }
    this.isRefreshing = true;
    try {
      const token = await this.tokens.refresh();
      this.waiters.forEach((w) => w(token ?? null));
      return token ?? null;
    } catch (e) {
      this.waiters.forEach((w) => w(null));
      await this.tokens.clearTokens();
      throw e;
    } finally {
      this.waiters = [];
      this.isRefreshing = false;
    }
  }

  request(config) {
    return this.axios.request(config);
  }
}

React Query Retry Strategy (Auth-Aware)

Retry once on 401 after triggering a refresh; otherwise, use bounded retries for network/server errors.

// services/query/ReactQueryService.js
import { QueryClient } from "@tanstack/react-query";

export class ReactQueryService {
  queryClient;

  constructor(logger) {
    this.logger = logger;
    this.queryClient = new QueryClient({
      defaultOptions: {
        queries: {
          staleTime: 60_000,
          retry: (failureCount, error) => {
            const status = error?.response?.status;
            if (status === 401) return failureCount < 1; // single retry after interceptor refresh
            if (!status || status >= 500) return failureCount < 2; // network/5xx
            return false;
          },
          refetchOnWindowFocus: false,
        },
        mutations: {
          retry: (failureCount, error) => {
            const status = error?.response?.status;
            if (!status || status >= 500) return failureCount < 1;
            return false;
          },
        },
      },
    });
  }
}

Auth State Slice (Consistent App Behavior)

Persist only what you must. Keep sensitive data in secure storage.

// state/authSlice.js
import { createSlice } from "@reduxjs/toolkit";

const initialState = {
  isAuthenticated: false,
  accessToken: null,
  refreshTokenPresent: false,
  expiresAt: null,
  lastRefreshAt: null,
};

export const authSlice = createSlice({
  name: "auth",
  initialState,
  reducers: {
    setSession: (s, { payload }) => {
      s.accessToken = payload.accessToken;
      s.expiresAt = payload.expiresAt;
      s.lastRefreshAt = Date.now();
      s.refreshTokenPresent = payload.hasRefresh;
      s.isAuthenticated = true;
    },
    updateAccessToken: (s, { payload }) => {
      s.accessToken = payload.accessToken;
      s.expiresAt = payload.expiresAt;
      s.lastRefreshAt = Date.now();
    },
    clearSession: (s) => {
      s.isAuthenticated = false;
      s.accessToken = null;
      s.expiresAt = null;
      s.lastRefreshAt = null;
      s.refreshTokenPresent = false;
    },
  },
});

Secure storage (Keychain/Keystore/SecureStore) should hold refresh tokens and, ideally, access tokens. Redux/state only needs derived flags and timestamps.

References: Expo SecureStoreAsyncStorage


App Lifecycle: Sync & Reset

When refresh fails, perform a clean reset: clear caches, disconnect SDKs, reset analytics, and navigate to sign-in.

// services/lifecycle/AppLifecycleService.js
export class AppLifecycleService {
  constructor(queryClient, tokenService, store, disconnectors = []) {
    this.queryClient = queryClient;
    this.tokenService = tokenService;
    this.store = store;
    this.disconnectors = disconnectors;
  }

  async fullSignOut() {
    await this.tokenService.clearTokens();
    this.store.dispatch({ type: "auth/clearSession" });

    await Promise.allSettled(this.disconnectors.map((fn) => fn()));
    await this.queryClient.clear();

    // Optionally reload app shell or navigate to Auth stack
    // e.g., Updates.reloadAsync() or navigation.reset(...)
  }
}

Optional: Biometrics as a Gate (Not a Provider)

Biometrics should unlock locally stored credentials/refresh tokens, not replace your server-side session model.

// services/auth/BiometricsService.js
export class BiometricsService {
  constructor(secure) {
    this.secure = secure;
  }

  async enable() {
    // probe hardware + enrollment; set a preference flag in secure storage
    await this.secure.setItem("biometricsEnabled", "true");
    return true;
  }

  async authenticate() {
    // call platform biometrics; if success, allow reading refresh token
    return true;
  }
}

Error Handling & Recovery

Classify and react:

  • 401: try single refresh; if it fails → clear session and route to sign-in.
  • 403: show “forbidden” and consider role/permission re-fetch.
  • 5xx / network: bounded retries + offline UI cues.
  • Unknown: log with minimal PII; degrade gracefully.
// central HTTP error hooks
function onHttpError(e, emit, log) {
  const status = e?.response?.status ?? 0;
  if (status === 401) emit("auth:unauthorized");
  else if (status === 403) emit("auth:forbidden");
  else if (!status) emit("network:down");
  log("http_error", { status, url: e?.config?.url });
}

Tests

Unit (TokenService)

// Test helper function
const createMockSecureStore = () => ({
  getItem: jest.fn(),
  setItem: jest.fn(),
  removeItem: jest.fn(),
});

it("refreshes and stores tokens", async () => {
  const store = createMockSecureStore();
  const mockAxios = {
    request: jest.fn().mockResolvedValue({
      data: {
        access_token: "A2",
        refresh_token: "R2",
        expires_in: 3600,
      },
    }),
  };
  const svc = new TokenService(store, mockAxios, {
    refreshUrl: "/auth/refresh",
  });

  await svc.setTokens({
    access_token: "A1",
    refresh_token: "R1",
    expires_in: 1,
  });
  await svc.refresh();

  expect(mockAxios.request).toHaveBeenCalled();
  expect(await store.getItem("accessToken")).toBe("A2");
});

Integration (Axios + Refresh)

it("retries once after 401 using queued refresh", async () => {
  const store = createMockSecureStore();
  const mockAxios = {
    request: jest.fn().mockResolvedValue({
      data: { access_token: "A2", expires_in: 3600 },
    }),
  };
  const svc = new TokenService(store, mockAxios, {
    refreshUrl: "/auth/refresh",
  });
  await svc.setTokens({
    access_token: "expired",
    refresh_token: "R1",
    expires_in: 1,
  });

  const http = new AxiosService(svc, "https://api.example.com");

  // First call 401, then refresh, then success
  mockAxios
    .onGet("/protected")
    .replyOnce(401)
    .onGet("/protected")
    .replyOnce(200, { ok: true });

  const res = await http.request({ method: "GET", url: "/protected" });
  expect(res.status).toBe(200);
});

Key Principles

  • Keep token logic in a single service; UI never touches tokens directly.
  • Refresh once and queue follow-up requests; prevent token storms.
  • Use secure storage for refresh tokens; keep Redux lean (flags/timestamps).
  • On refresh failure, clear session and reset app state deterministically.
  • Test unit + integration paths: expired tokens, network loss, revoked refresh.
  • Log minimally and avoid sensitive data in telemetry.