Mobile Auth Integration Guide (React Native)
This document describes how to integrate authentication in the React Native mobile app. The backend serves both web and mobile clients from the same endpoints — the difference is how tokens are delivered.
How It Works
- Web clients receive refresh tokens via httpOnly secure cookies (automatic browser handling).
- Mobile clients receive refresh tokens in the JSON response body and must send them back in the request body.
The backend distinguishes clients by the X-Client-Type header.
Required Headers
All Requests
X-Client-Type: mobile
Content-Type: application/json
Authenticated Requests (additionally)
Authorization: Bearer <accessToken>
Endpoints
POST /api/auth/register
Create a new account.
Request:
{
"email": "kid@example.com",
"username": "coolkid",
"password": "SecurePass1",
"dateOfBirth": "2014-05-15",
"displayName": "Cool Kid"
}
displayNameis optional.- Password must meet validation rules (min 8 chars, at least 1 uppercase, 1 number).
Response (201):
{
"success": true,
"data": {
"user": {
"id": "clx...",
"email": "kid@example.com",
"username": "coolkid",
"isMinor": true,
"requiresParentalConsent": true,
"createdAt": "2026-04-17T..."
},
"accessToken": "<jwt>",
"refreshToken": "<jwt>"
}
}
Note: refreshToken is only present when X-Client-Type: mobile header is sent.
POST /api/auth/login
Authenticate with email or username.
Request:
{
"identifier": "coolkid",
"password": "SecurePass1"
}
identifieraccepts either email or username.
Response (200):
{
"success": true,
"data": {
"user": {
"id": "clx...",
"email": "kid@example.com",
"username": "coolkid"
},
"accessToken": "<jwt>",
"refreshToken": "<jwt>"
}
}
POST /api/auth/refresh
Exchange a valid refresh token for new access + refresh tokens. This implements token rotation — the old refresh token is invalidated on use.
Request:
{
"refreshToken": "<current-refresh-token>"
}
Response (200):
{
"success": true,
"data": {
"accessToken": "<new-jwt>",
"refreshToken": "<new-jwt>"
}
}
Important edge case: If refreshToken is absent in the response (but accessToken is present), it means another concurrent request already rotated the token. Keep using your stored refresh token — if that also fails with 401, the user must re-login.
POST /api/auth/logout
Revoke the refresh token and destroy the session.
Request:
{
"refreshToken": "<current-refresh-token>"
}
Response (200):
{
"success": true,
"message": "Logged out successfully"
}
Token Expiry
| Token | Expiry |
|---|---|
| Access token | 15 minutes |
Refresh token (JWT exp + server-side) | 2 days |
The access token expires every 15 minutes. When a 401 response is received, silently refresh using the stored refresh token before retrying the request.
Error Responses
All errors follow this shape:
{
"success": false,
"error": "Error message here"
}
Key status codes:
| Code | Meaning | Action |
|---|---|---|
| 401 | Token expired, invalid, or revoked | Attempt refresh; if refresh fails, navigate to login |
| 409 | Email or username already taken | Show registration error |
| 400 | Validation error | Show field-level errors |
Secure Token Storage
Never use AsyncStorage for tokens. Use encrypted storage:
With Expo
import * as SecureStore from 'expo-secure-store';
async function saveTokens(accessToken: string, refreshToken: string) {
await SecureStore.setItemAsync('accessToken', accessToken);
await SecureStore.setItemAsync('refreshToken', refreshToken);
}
async function getAccessToken(): Promise<string | null> {
return SecureStore.getItemAsync('accessToken');
}
async function getRefreshToken(): Promise<string | null> {
return SecureStore.getItemAsync('refreshToken');
}
async function clearTokens() {
await SecureStore.deleteItemAsync('accessToken');
await SecureStore.deleteItemAsync('refreshToken');
}
With Bare React Native
import * as Keychain from 'react-native-keychain';
async function saveTokens(accessToken: string, refreshToken: string) {
await Keychain.setInternetCredentials('accessToken', 'token', accessToken);
await Keychain.setInternetCredentials('refreshToken', 'token', refreshToken);
}
async function getAccessToken(): Promise<string | null> {
const creds = await Keychain.getInternetCredentials('accessToken');
return creds ? creds.password : null;
}
async function getRefreshToken(): Promise<string | null> {
const creds = await Keychain.getInternetCredentials('refreshToken');
return creds ? creds.password : null;
}
async function clearTokens() {
await Keychain.resetInternetCredentials('accessToken');
await Keychain.resetInternetCredentials('refreshToken');
}
Axios Setup with Auto-Refresh
Complete interceptor pattern with queue for concurrent requests:
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
const API_URL = __DEV__ ? 'http://<local-ip>:3001' : 'https://<prod-domain>';
const api = axios.create({
baseURL: `${API_URL}/api`,
headers: {
'X-Client-Type': 'mobile',
'Content-Type': 'application/json',
},
});
// --- Request interceptor: attach access token ---
api.interceptors.request.use(async (config) => {
const token = await getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// --- Response interceptor: handle 401 with silent refresh ---
let isRefreshing = false;
let failedQueue: Array<{
resolve: (token: string) => void;
reject: (error: unknown) => void;
}> = [];
function processQueue(error: unknown, token: string | null) {
failedQueue.forEach(({ resolve, reject }) => {
error ? reject(error) : resolve(token!);
});
failedQueue = [];
}
api.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
// Only intercept 401s, skip if already retried or if it's an auth endpoint
if (
error.response?.status !== 401 ||
originalRequest._retry ||
originalRequest.url?.includes('/auth/')
) {
return Promise.reject(error);
}
// Queue concurrent requests while refresh is in progress
if (isRefreshing) {
return new Promise<string>((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return api(originalRequest);
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
const refreshToken = await getRefreshToken();
if (!refreshToken) throw new Error('No refresh token');
// Use a fresh axios instance (not `api`) to avoid interceptor loop
const { data } = await axios.post(
`${API_URL}/api/auth/refresh`,
{ refreshToken },
{
headers: {
'X-Client-Type': 'mobile',
'Content-Type': 'application/json',
},
},
);
const newAccessToken: string = data.data.accessToken;
await saveTokens(newAccessToken, data.data.refreshToken ?? refreshToken);
processQueue(null, newAccessToken);
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
return api(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
await clearTokens();
// Navigate to login screen — use your navigation ref here
// e.g., navigationRef.current?.navigate('Login');
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
},
);
export default api;
App Lifecycle
On App Launch
Attempt a silent refresh to restore the session:
async function initializeAuth(): Promise<boolean> {
const refreshToken = await getRefreshToken();
if (!refreshToken) return false;
try {
const { data } = await axios.post(
`${API_URL}/api/auth/refresh`,
{ refreshToken },
{
headers: {
'X-Client-Type': 'mobile',
'Content-Type': 'application/json',
},
},
);
await saveTokens(data.data.accessToken, data.data.refreshToken ?? refreshToken);
return true;
} catch {
await clearTokens();
return false;
}
}
On Foreground Resume
When the app returns from background, check if the access token is still valid. If the app was backgrounded for more than 15 minutes, trigger a refresh:
import { AppState, AppStateStatus } from 'react-native';
let lastActiveTime = Date.now();
AppState.addEventListener('change', async (state: AppStateStatus) => {
if (state === 'active') {
const elapsed = Date.now() - lastActiveTime;
// If backgrounded for more than 14 minutes, proactively refresh
if (elapsed > 14 * 60 * 1000) {
const refreshToken = await getRefreshToken();
if (refreshToken) {
try {
const { data } = await axios.post(
`${API_URL}/api/auth/refresh`,
{ refreshToken },
{
headers: {
'X-Client-Type': 'mobile',
'Content-Type': 'application/json',
},
},
);
await saveTokens(data.data.accessToken, data.data.refreshToken ?? refreshToken);
} catch {
await clearTokens();
// Navigate to login
}
}
}
} else if (state === 'background') {
lastActiveTime = Date.now();
}
});
Testing with curl
# Register (mobile)
curl -X POST http://localhost:3001/api/auth/register \
-H "Content-Type: application/json" \
-H "X-Client-Type: mobile" \
-d '{"email":"mobiletest@test.com","username":"mobileuser","password":"Password123","dateOfBirth":"2014-01-01"}'
# Login (mobile)
curl -X POST http://localhost:3001/api/auth/login \
-H "Content-Type: application/json" \
-H "X-Client-Type: mobile" \
-d '{"identifier":"mobileuser","password":"Password123"}'
# Refresh (mobile) — use refreshToken from login response
curl -X POST http://localhost:3001/api/auth/refresh \
-H "Content-Type: application/json" \
-H "X-Client-Type: mobile" \
-d '{"refreshToken":"<token-from-login>"}'
# Logout (mobile)
curl -X POST http://localhost:3001/api/auth/logout \
-H "Content-Type: application/json" \
-H "X-Client-Type: mobile" \
-d '{"refreshToken":"<current-refresh-token>"}'
# Authenticated request example
curl http://localhost:3001/api/users/me \
-H "Authorization: Bearer <accessToken>" \
-H "X-Client-Type: mobile"
Security Notes
- Always use SecureStore/Keychain for token storage. AsyncStorage is not encrypted.
- Refresh tokens rotate on every use — after refreshing, the old token is invalidated. Always store the new refresh token from the response.
- Token reuse detection — if a revoked refresh token is used (possible token theft), ALL of the user's sessions are revoked for safety. The user must re-login on all devices.
- Access tokens are short-lived (15 min) — even if intercepted, the window of exposure is small.
- Send a proper User-Agent header — the backend uses it for device tracking and session management. Example:
KSN-Mobile/1.0 (iOS 17.0)orKSN-Mobile/1.0 (Android 14).