Skip to main content
Version: Latest
UPDATED 2026-11-03 — Every user object here now also carries grade and school (both string | null, from the user's current school) next to isStudentVerified — show them under the name. null = no current school on file. Only sent to student-verified viewers: an unverified viewer gets neither key on anyone but themselves, so treat missing as "hidden". Additive for verified users. Details: [Student verification guide](../student-verification/mobile-student-verification-guide.md#the-verified-badge).
-H "X-Client-Type: mobile" \ -d '{"email":"mobiletest@test.com","username":"mobileuser","password":"Password123","dateOfBirth":"2011-01-01"}' -d '{"email":"mobiletest@test.com","username":"mobileuser","password":"<test-password>","dateOfBirth":"2011-01-01"}'
-H "X-Client-Type: mobile" \ -d '{"identifier":"mobileuser","password":"Password123"}' -d '{"identifier":"mobileuser","password":"<test-password>"}'

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.

What's new​

  • 2026-10-07 — Every user object here now also carries grade and school (both string | null, from the user's current school) next to isStudentVerified — show them under the name. null = no current school on file. Only sent to student-verified viewers: an unverified viewer gets neither key on anyone but themselves, so treat missing as "hidden". Additive for verified users. Details: Student verification guide.
  • 2026-10-05 — Signup now rejects anyone over 25 (was 100). Same 422 INVALID_AGE as the under-13 case — show it on the date-of-birth field, and cap your date picker so users can't pick a birth date older than 25 years. Existing accounts are untouched.
  • 2026-10-05 — Every user object in login / register responses (user) now carries isStudentVerified: boolean — show the verified badge when true. Additive: nothing breaks if you ignore it. Details: Student verification guide.
  • 2026-09-30 — Login's response now includes data.user.onboardingCompleted. When it's false, the account exists but the signup wizard was never finished — send the user into the onboarding flow (starting at the education screen) instead of the app. This matters now that onboarding creates the account on its email + password screen, so anyone who quits mid-wizard has a real, loginable account with a half-filled profile. GET /api/users/me carries the same field. Nothing else about login changed.
  • 2026-09-15 — Two new optional request headers: X-Request-Id (a fresh unique id per request) and X-Client-Version (your build, e.g. ios-6.1.0(412)). Nothing breaks if you skip them, but sending both is what lets us trace one user's session through a server restart — without them a 401 report is close to undebuggable. See "Optional: debug headers" below. Nothing else changed: same endpoints, same bodies, same 401 message.

Required Headers​

All Requests​

X-Client-Type: mobile
Content-Type: application/json

Authenticated Requests (additionally)​

Authorization: Bearer <accessToken>

Optional: debug headers​

X-Request-Id: <a fresh unique id per request, e.g. a uuid>
X-Client-Version: ios-6.1.0(412)

Both are optional and neither changes a response. Send them anyway.

The server logs X-Request-Id on every line it writes for that request, so one id pulls up the whole story of a single call. Without it the server falls back to a counter that restarts from 1 every time the API scales or restarts — which it does whenever the app has been idle — so two unrelated requests can both be req-1 and nothing correlates. X-Client-Version is the only way to tie a report to a build; the user-agent we receive is just clustr/6 CFNetwork/… and does not carry your version.

Reuse the same X-Request-Id across a retry only if you mean "this is the same logical attempt"; otherwise generate a new one.


Endpoints​

POST /api/auth/register​

Create a new account.

Request:

{
"email": "kid@example.com",
"username": "coolkid",
"password": "SecurePass1",
"dateOfBirth": "2011-05-15",
"displayName": "Cool Kid"
}
  • displayName is optional.
  • Password must meet validation rules (min 8 chars, at least 1 uppercase, 1 number).
  • dateOfBirth must produce an age of 13–25. Under 13 or over 25 is rejected with a 422 — surface it on the date field, not as a generic error.

Response (201):

{
"success": true,
"data": {
"user": {
"id": "clx...",
"email": "kid@example.com",
"username": "coolkid",
"isMinor": true,
"requiresParentalConsent": false,
"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"
}
  • identifier accepts either email or username.

Response (200):

{
"success": true,
"data": {
"user": {
"id": "clx...",
"email": "kid@example.com",
"username": "coolkid",
"onboardingCompleted": true
},
"accessToken": "<jwt>",
"refreshToken": "<jwt>"
}
}

onboardingCompleted: false ⇒ route to the onboarding flow, not the app. The account exists (onboarding creates it on its email + password screen), but the user never reached the end of the wizard — resume it at the education screen. See the Mobile Onboarding guide; POST /api/users/me/onboarding/complete is what flips this to true. GET /api/users/me returns the same field.


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​

TokenExpiry
Access token15 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:

CodeMeaningAction
401Token expired, invalid, or revokedAttempt refresh; if refresh fails, navigate to login
409Email or username already takenShow registration error
400Validation errorShow 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":"<test-password>","dateOfBirth":"2011-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":"<test-password>"}'

# 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​

  1. Always use SecureStore/Keychain for token storage. AsyncStorage is not encrypted.
  2. Refresh tokens rotate on every use — after refreshing, the old token is invalidated. Always store the new refresh token from the response.
  3. 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.
  4. Access tokens are short-lived (15 min) — even if intercepted, the window of exposure is small.
  5. Send a proper User-Agent header — the backend uses it for device tracking and session management. Example: KSN-Mobile/1.0 (iOS 17.0) or KSN-Mobile/1.0 (Android 14).
⤓ Download .md