A practical guide to secure token storage, authentication flows, session restoration, and reliable logout behavior.
Login is often one of the first features added to a React Native application. It is also one of the easiest to underestimate. A successful API response and a redirect to the home screen may work during development, but real users close applications, lose connectivity, switch accounts, and return after their credentials have expired.
Reliable React Native authentication requires a coordinated session lifecycle. Storage, navigation, API requests, and cached user data must agree about which session is active.
This guide focuses on applications that use access and refresh tokens. Authentication SDKs may manage parts of this lifecycle internally, so custom logic should complement the chosen SDK rather than duplicate it.
Start With an Explicit Session Model
A single isLoggedIn boolean cannot describe everything happening during authentication. When an application starts, it may still be reading stored credentials. If that temporary uncertainty is treated as a signed-out state, the login screen can appear briefly before the authenticated interface loads.
A more useful model distinguishes restoration, authentication, and recovery:
type User = {
id: string;
displayName: string;
};
type SessionState =
| { status: 'restoring' }
| { status: 'signedOut' }
| { status: 'signedIn'; user: User }
| {
status: 'restoreError';
reason: 'network' | 'storage';
};
This is an application design example, not a required library interface. Its purpose is to make ambiguous states explicit.
A session controller should own transitions between these states. Screens can consume its state through React Context, Zustand, Redux, or another suitable mechanism. Authentication logic should not be independently recreated inside individual screens.
Tokens can remain in a dedicated session service while UI state exposes only the information needed for rendering. Most components need the current user and authentication status, not the refresh token.
Design Login Around the Authentication Provider
For OAuth-based native applications, the standards-based approach uses an authorization code flow with PKCE and an external user-agent, typically a system browser authentication session. RFC 8252 requires PKCE for public native clients and specifies external user-agents instead of embedded login WebViews.
An established provider SDK can handle the protocol details, including redirects and response validation. An application should not ship an OAuth client secret and assume it remains confidential inside the mobile bundle.
After authentication succeeds, the application should establish its session in a deliberate sequence:
- Accept and validate the authentication result through the chosen SDK or authentication layer.
- Persist any credential needed to restore the session.
- Initialize the in-memory access token and user context.
- Publish the signed-in state.
If persistence fails, the application needs an explicit policy. It might reject session establishment or allow a clearly defined temporary session. Silently presenting a durable login when credentials were never saved creates unpredictable behavior after restart.
The backend must still authorize protected operations. Rendering an authenticated screen does not grant permission to access its underlying data.
Store Credentials According to Their Sensitivity
React Native’s security documentation describes AsyncStorage as unencrypted storage and explicitly advises against using it for tokens or secrets. It also warns about accidental exposure through persisted application state and monitoring services.
For a custom token-based implementation, a reasonable starting design is:
| Data | Suggested handling |
|---|---|
| Access token | Keep in memory where practical |
| Refresh token | Persist through platform-backed secure storage |
| Password | Do not persist for automatic login |
| Display preferences | Use ordinary persistent storage if non-sensitive |
| Account-specific cached data | Apply a separate policy based on sensitivity |
| OAuth client secret | Do not embed in a public mobile application |
Expo SecureStore is one option for protected local storage. Its asynchronous interface can be wrapped behind a small credential-storage module:
import * as SecureStore from 'expo-secure-store';
const REFRESH_TOKEN_KEY = 'auth.refreshToken';
export const credentialStorage = {
read: () =>
SecureStore.getItemAsync(REFRESH_TOKEN_KEY),
write: (token: string) =>
SecureStore.setItemAsync(REFRESH_TOKEN_KEY, token),
remove: () =>
SecureStore.deleteItemAsync(REFRESH_TOKEN_KEY),
};
These methods can fail, so their callers must handle errors. The example intentionally leaves platform-specific options to the application’s security requirements.
Secure storage protects persisted credentials; it does not validate them. A successfully retrieved token may still be expired, revoked, or associated with a session that the server no longer accepts.
Restore the Session Before Rendering Protected Screens
At startup, the session controller should begin in restoring. It can then read the stored credential and attempt the appropriate restoration operation.
The outcome should determine the next state:
| Restoration result | Application behavior |
|---|---|
| No stored credential | Show the signed-out experience |
| Refresh succeeds | Store any replacement credential and establish the session |
| Server definitively rejects the credential | Clear invalid credentials and require login |
| Network is unavailable | Offer retry or a deliberately restricted offline experience |
| Secure storage is temporarily inaccessible | Show recovery guidance without assuming revocation |
A timeout does not establish that a refresh token is invalid. Deleting credentials after every network failure turns unreliable connectivity into repeated login prompts.
Offline access requires its own policy. An application may permit previously downloaded content while preventing operations that need server authorization. A cached user profile alone should not establish a fresh authenticated session.
Let Authentication State Control Navigation
React Navigation documents a conditional-screen approach: authenticated screens exist when the user is signed in, and authentication screens exist when the user is signed out. When the condition changes, the navigator selects the appropriate available screen. Manual navigation to the home screen is unnecessary in that setup.
A simplified example using the dynamic API looks like this:
function RootNavigator() {
const session = useSession();
if (session.status === 'restoring') {
return <SplashScreen />;
}
if (session.status === 'restoreError') {
return <SessionRecoveryScreen />;
}
return (
<NavigationContainer>
<Stack.Navigator>
{session.status === 'signedIn' ? (
<Stack.Screen
name="App"
component={AuthenticatedNavigator}
/>
) : (
<Stack.Screen
name="SignIn"
component={SignInScreen}
/>
)}
</Stack.Navigator>
</NavigationContainer>
);
}
The referenced components and useSession are application-defined. The important relationship is that session state determines the available routes.
Protected deep links should follow the same rules. An incoming link can identify a destination, but the application must establish authentication and check authorization before displaying protected content.
Refresh Tokens Without Creating Request Races
An expired access token can affect several concurrent requests. If each request independently starts a refresh, the application may create competing credential updates.
OAuth’s current security best practice requires refresh tokens issued to public clients to be sender-constrained or use rotation. With rotation, a successful refresh replaces the previous refresh token. Reusing an invalidated token can trigger replay detection.
For a custom session layer, a useful coordination pattern is single-flight refresh: concurrent callers wait for one shared refresh operation.
That operation should belong to the current session. Once it succeeds, the application persists any replacement refresh token before releasing waiting requests. A failed persistence step needs recovery handling because continuing with an obsolete stored token can break restoration later.
Refresh decisions also need boundaries. A centralized API client should:
- Refresh only for an authentication failure that the backend contract identifies as refreshable.
- Avoid intercepting its own refresh request recursively.
- Limit automatic retries.
- Keep permission errors separate from expired-session errors.
- Retry mutations only when replay is safe, using backend-supported idempotency where appropriate.
An arbitrary timeout is especially dangerous as a retry trigger: the server may already have processed the original operation.
Make Logout Invalidate the Entire Local Session
Deleting a token is only one part of logout. The application may still have active requests, cached account data, WebSocket subscriptions, or a refresh operation about to complete.
A robust local logout should immediately mark the current session obsolete and prevent further authenticated work. It should then cancel requests where possible, close session-owned connections, clear account-specific caches, and delete persisted credentials.
A useful implementation technique is a session generation counter. Every login or logout changes the generation. Asynchronous work captures the generation when it starts and checks it before publishing results.
If a refresh response arrives after logout, its old generation prevents it from restoring the session. The same protection helps when one user logs out and another signs in before older requests finish.
Credential writes and deletion also need serialization or equivalent coordination. A late secure-storage write must not recreate a refresh token after logout has removed it.
Server-side revocation is a separate operation. RFC 7009 defines a token-revocation endpoint, but the impact on related tokens depends on server behavior. Local deletion alone does not invalidate every previously issued access token.
If connectivity prevents revocation, local logout should still complete. The application should not claim that remote sessions have been terminated. Provider browser sessions may also require a separate sign-out flow.
Recheck Sessions When the Application Returns
React Native’s AppState API exposes foreground and background transitions. It can support a check when the application becomes active again.
That check should use the same session controller and refresh coordination as API requests. Starting an independent refresh from every screen or foreground event recreates the concurrency problem.
Foreground checks are useful because mobile applications should not depend on continuously running background timers. Before protected work resumes, the session layer can examine its current token state and refresh when necessary.
Test the Session Boundaries
The most revealing authentication tests involve transitions rather than a successful login alone. Verification should cover:
- Restarting with valid, expired, and rejected credentials.
- Restoring while offline or while secure storage is unavailable.
- Several requests requiring refresh simultaneously.
- Logging out during refresh.
- Switching accounts while earlier requests remain in flight.
- Opening a protected deep link while signed out.
- Failing to persist a rotated refresh token.
- Confirming that logout removes the previous account’s cached data.
The central design principle is ownership. One session layer owns credentials and transitions, navigation reflects that state, and the backend authorizes each protected operation. When those responsibilities stay clear, login, logout, and session restoration become easier to reason about and maintain.





















Add Comment