Skip to content

Auth system (JWT + refresh + secure storage)

Here’s a production-grade auth system for Expo + Bun using:

  • πŸ” JWT (access + refresh tokens)
  • πŸ”„ Auto refresh flow
  • πŸ”’ Secure storage (device-safe)
  • 🧠 State via Zustand
  • ⚑ API handling via TanStack Query

This is how real fintech / SaaS apps handle auth.


Login β†’ Receive access + refresh token
↓
Store securely (SecureStore)
↓
Attach access token to API calls
↓
If expired β†’ use refresh token
↓
If refresh fails β†’ logout user

import * as SecureStore from 'expo-secure-store';
export const secureStorage = {
set: async (key: string, value: string) => {
await SecureStore.setItemAsync(key, value);
},
get: async (key: string) => {
return await SecureStore.getItemAsync(key);
},
remove: async (key: string) => {
await SecureStore.deleteItemAsync(key);
},
};

import { create } from 'zustand';
type AuthState = {
accessToken: string | null;
refreshToken: string | null;
setTokens: (access: string, refresh: string) => void;
logout: () => void;
};
export const useAuthStore = create<AuthState>((set) => ({
accessToken: null,
refreshToken: null,
setTokens: (access, refresh) =>
set({ accessToken: access, refreshToken: refresh }),
logout: () =>
set({ accessToken: null, refreshToken: null }),
}));

import axios from 'axios';
import { useAuthStore } from '@/features/auth/store';
export const apiClient = axios.create({
baseURL: 'https://api.example.com',
});
apiClient.interceptors.request.use((config) => {
const token = useAuthStore.getState().accessToken;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});

import { apiClient } from './client';
import { useAuthStore } from '@/features/auth/store';
let isRefreshing = false;
let queue: any[] = [];
export const refreshToken = async () => {
const { refreshToken, setTokens, logout } =
useAuthStore.getState();
if (!refreshToken) return logout();
try {
const { data } = await apiClient.post('/auth/refresh', {
refreshToken,
});
setTokens(data.accessToken, data.refreshToken);
return data.accessToken;
} catch (e) {
logout();
throw e;
}
};
export const handleTokenRefresh = async (error: any) => {
const originalRequest = error.config;
if (error.response?.status !== 401) {
return Promise.reject(error);
}
if (isRefreshing) {
return new Promise((resolve) => {
queue.push((token: string) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
resolve(apiClient(originalRequest));
});
});
}
isRefreshing = true;
try {
const newToken = await refreshToken();
queue.forEach((cb) => cb(newToken));
queue = [];
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return apiClient(originalRequest);
} finally {
isRefreshing = false;
}
};

apiClient.interceptors.response.use(
(res) => res,
async (error) => handleTokenRefresh(error)
);

import { secureStorage } from '@/services/storage/secure';
import { useAuthStore } from './store';
const ACCESS = 'access_token';
const REFRESH = 'refresh_token';
export const saveTokens = async (access: string, refresh: string) => {
await secureStorage.set(ACCESS, access);
await secureStorage.set(REFRESH, refresh);
};
export const loadTokens = async () => {
const access = await secureStorage.get(ACCESS);
const refresh = await secureStorage.get(REFRESH);
if (access && refresh) {
useAuthStore.getState().setTokens(access, refresh);
}
};
export const clearTokens = async () => {
await secureStorage.remove(ACCESS);
await secureStorage.remove(REFRESH);
};

import { apiClient } from '@/services/api/client';
export const login = async (email: string, password: string) => {
const { data } = await apiClient.post('/auth/login', {
email,
password,
});
return data;
};

import { useMutation } from '@tanstack/react-query';
import { login } from './api';
import { saveTokens } from './persist';
import { useAuthStore } from './store';
export const useLogin = () => {
const setTokens = useAuthStore((s) => s.setTokens);
return useMutation({
mutationFn: ({ email, password }: any) =>
login(email, password),
onSuccess: async (data) => {
setTokens(data.accessToken, data.refreshToken);
await saveTokens(data.accessToken, data.refreshToken);
},
});
};

import { clearTokens } from './persist';
import { useAuthStore } from './store';
export const useLogout = () => {
const logout = useAuthStore((s) => s.logout);
return async () => {
logout();
await clearTokens();
};
};

import { useEffect, useState } from 'react';
import { loadTokens } from '@/features/auth/persist';
import { Stack } from 'expo-router';
export default function RootLayout() {
const [ready, setReady] = useState(false);
useEffect(() => {
loadTokens().finally(() => setReady(true));
}, []);
if (!ready) return null;
return <Stack />;
}

const token = useAuthStore((s) => s.accessToken);
if (!token) {
return <Redirect href="/login" />;
}

  • App starts β†’ tokens loaded from secure storage
  • Requests auto include JWT
  • Token expires β†’ auto refresh
  • Refresh fails β†’ user logged out
  • Offline β†’ requests queued (if you implemented earlier system)

βœ… Always use refresh token rotation βœ… Keep access token short-lived (5–15 min) βœ… Never store tokens in plain AsyncStorage βœ… Handle multi-request refresh (queue system) βœ… Log auth failures (important for security)


  • Biometric unlock (FaceID / Fingerprint)
  • Token binding to device
  • Silent background refresh
  • Role-based access control (RBAC)
  • Session timeout handling

If you want next level (true enterprise/mobile banking level), I can show you:

  • πŸ”₯ OAuth (Google / Apple login via Expo)
  • 🧠 Multi-device session management
  • ⚑ Web + Mobile shared auth architecture
  • πŸ” End-to-end encrypted storage strategy