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.ts
export interface Tokens {
  accessToken: string | null;
  refreshToken: string | null;
  expiresAt: number | null; // epoch ms
}

export interface TokenEndpointResponse {
  access_token: string;
  refresh_token?: string;
  expires_in: number; // seconds
}

export interface ITokenStore {
  getItem(key: string): Promise<string | null>;
  setItem(key: string, val: string): Promise<void>;
  removeItem(key: string): Promise<void>;
}

export interface IAxiosService {
  request<T= any>(config: any): Promise<{ data: T }>;
}

export class TokenService {
  constructor(
    private readonly store: ITokenStore, // SecureStore/Keychain
    private readonly axios: IAxiosService,
    private readonly config: { refreshUrl: string; clientId?: string }
  ) {}

  async getTokens(): Promise<Tokens> {
    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: TokenEndpointResponse) {
    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: number | null, skewMs = 15_000) {
    return !expiresAt || Date.now() + skewMs >= expiresAt;
  }

  // Exchange refresh token for new access token (RFC 6749 §6)
  async refresh(): Promise<string | null> {
    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 as TokenEndpointResponse;
    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.ts
import axios, {
  AxiosError,
  AxiosInstance,
  AxiosRequestConfig,
  AxiosResponse,
} from "axios";
import { TokenService } from "../auth/TokenService";

export class AxiosService {
  private axios: AxiosInstance;
  private isRefreshing = false;
  private waiters: Array<(token: string | null) => void> = [];

  constructor(private readonly tokens: TokenService, baseURL: string) {
    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: AxiosError) => {
        const status = error.response?.status ?? 0;
        const original = error.config as AxiosRequestConfig & {
          _retry?: boolean;
        };

        // 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);
      }
    );
  }

  private async refreshOnce(): Promise<string | null> {
    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<T= any>(config: AxiosRequestConfig): Promise<AxiosResponse<T>> {
    return this.axios.request<T>(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.ts
import { QueryClient } from "@tanstack/react-query";
import { AxiosError } from "axios";

export class ReactQueryService {
  readonly queryClient: QueryClient;

  constructor(private readonly logger: { debug: Function }) {
    this.queryClient = new QueryClient({
      defaultOptions: {
        queries: {
          staleTime: 60_000,
          retry: (failureCount, error) => {
            const status = (error as AxiosError)?.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 as AxiosError)?.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.ts
import { createSlice, PayloadAction } from "@reduxjs/toolkit";

export interface AuthState {
  isAuthenticated: boolean;
  accessToken: string | null;
  refreshTokenPresent: boolean; // Boolean flag only; store actual token in secure storage
  expiresAt: number | null;
  lastRefreshAt: number | null;
}

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

export const authSlice = createSlice({
  name: "auth",
  initialState,
  reducers: {
    setSession: (
      s,
      {
        payload,
      }: PayloadAction<{
        accessToken: string;
        expiresAt: number;
        hasRefresh: boolean;
      }>
    ) => {
      s.accessToken = payload.accessToken;
      s.expiresAt = payload.expiresAt;
      s.lastRefreshAt = Date.now();
      s.refreshTokenPresent = payload.hasRefresh;
      s.isAuthenticated = true;
    },
    updateAccessToken: (
      s,
      { payload }: PayloadAction<{ accessToken: string; expiresAt: number }>
    ) => {
      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.ts
export class AppLifecycleService {
  constructor(
    private readonly queryClient: import("@tanstack/react-query").QueryClient,
    private readonly tokenService: import("../auth/TokenService").TokenService,
    private readonly store: { dispatch: Function },
    private readonly disconnectors: Array<() => Promise<void>> = []
  ) {}

  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.ts
export class BiometricsService {
  constructor(private readonly secure: ITokenStore) {}

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

  async authenticate(): Promise<boolean> {
    // 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: any,
  emit: (ev: string, data?: any) => void,
  log: (msg: string, meta?: any) => void
) {
  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)

it("refreshes and stores tokens", async () => {
  const store = fakeSecureStore();
  const mockAxios = {
    request: jest.fn().mockResolvedValue({
      data: {
        access_token: "A2",
        refresh_token: "R2",
        expires_in: 3600,
      },
    }),
  } as any;
  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 mockAxios = {
    request: jest.fn().mockResolvedValue({
      data: { access_token: "A2", expires_in: 3600 },
    }),
  } as any;
  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.