Authentication is one of the most important building blocks of modern mobile applications. Whether you're creating an e-commerce platform, a social media app, or a business management tool, users expect secure login, seamless session management, and reliable protection of their personal data.
A common mistake in mobile applications is relying on a single long-lived JSON Web Token (JWT). While this approach is simple to implement, it increases security risks because a stolen token remains valid until it expires.
A more secure and scalable solution combines short-lived access tokens with long-lived refresh tokens. The access token is used to authenticate API requests, while the refresh token is securely stored and used to obtain a new access token when the current one expires. This approach improves security without forcing users to log in repeatedly.
In this tutorial, you'll build a complete authentication system using React Native and Node.js with Express and MongoDB. The backend will issue JWT access tokens and refresh tokens, while the React Native app will automatically renew expired tokens using Axios interceptors, providing a smooth user experience.
You'll also implement user registration, login, protected API routes, persistent authentication, logout with refresh token revocation, and several security best practices commonly used in production applications.
What You'll Learn
By following this tutorial, you will learn how to:
- Create a REST API using Express 5
- Connect Express to MongoDB using Mongoose
- Register new users securely
- Hash passwords with bcrypt
- Generate JWT access tokens
- Issue refresh tokens
- Store refresh tokens in the database
- Protect API endpoints with middleware
- Build a React Native authentication flow
- Create reusable authentication context
- Configure Axios interceptors
- Automatically refresh expired tokens
- Persist user sessions
- Implement secure logout
- Apply production-ready authentication practices
Final Project Architecture
React Native App
│
├── Login Screen
├── Register Screen
├── Home Screen
├── Auth Context
├── Axios Client
└── AsyncStorage
│
│
▼
Node.js + Express API
│
├── Authentication Routes
├── User Routes
├── JWT Middleware
├── Refresh Token Service
└── MongoDB
Before starting, you should have:
- Basic JavaScript knowledge
- Familiarity with React fundamentals
- Basic understanding of REST APIs
- Node.js 24 or newer installed
- MongoDB Community Edition or MongoDB Atlas
- React Native development environment configured
- Android Studio or Xcode
- Visual Studio Code
Some experience with Express or React Native is helpful but not required, as every step will be explained in detail.
Technologies Used
| Technology | Purpose |
|---|---|
| React Native | Mobile application |
| React Navigation 7 | Navigation |
| Axios | HTTP requests |
| AsyncStorage | Persist tokens |
| Node.js | Backend runtime |
| Express 5 | REST API |
| MongoDB | Database |
| Mongoose | ODM |
| bcrypt | Password hashing |
| JWT | Authentication |
| dotenv | Environment variables |
| cors | Cross-origin support |
Prerequisites
Before building the authentication system, make sure your development environment is properly configured. In this section, you'll install the required software and verify that everything is working correctly.
Software Requirements
The tutorial uses the following tools and versions:
| Software | Recommended Version |
|---|---|
| Node.js | 24 LTS or newer |
| npm | Latest (comes with Node.js) |
| MongoDB Community Server or MongoDB Atlas | Latest |
| React Native | 0.83+ |
| Android Studio | Latest |
| Xcode (macOS only) | Latest |
| Visual Studio Code | Latest |
| Git | Latest |
Note: Although the tutorial targets React Native 0.83+, the concepts and authentication flow also apply to slightly older versions with minimal changes.
Install Node.js
Download and install the latest LTS version of Node.js from the official website.
After installation, verify that Node.js and npm are available:
node -v
Example output:
v24.18.0
Check npm:
npm -v
Example:
11.16.0
Install MongoDB
You have two options:
Option 1: MongoDB Community Server
Install MongoDB locally on your computer.
Verify the installation:
mongod --version
Start the MongoDB server:
mongod
The default connection string is:
mongodb://localhost:27017
Option 2: MongoDB Atlas (Recommended)
If you prefer a cloud-hosted database:
- Create a free MongoDB Atlas account.
- Create a new cluster.
- Create a database user.
- Whitelist your IP address.
- Copy the connection string.
Example:
mongodb+srv://username:[email protected]/reactnativeauth
We'll use this connection string later in the Express application.
Verify React Native Development Environment
If you haven't already configured React Native, follow the official setup instructions for your operating system.
After installation, verify that React Native tooling is available.
For Android, ensure:
- Android Studio is installed.
- Android SDK is configured.
- An Android Virtual Device (AVD) is created.
For macOS users developing for iOS:
- Install Xcode.
- Install Xcode Command Line Tools.
- Install CocoaPods.
Install Visual Studio Code
Visual Studio Code provides an excellent development experience for both backend and mobile applications.
Useful extensions include:
- ESLint
- Prettier
- React Native Tools
- ES7+ React/Redux Snippets
- MongoDB for VS Code
- Thunder Client (or Postman for API testing)
Verify Git Installation
Check your Git installation:
git --version
Example:
git version 2.50.1 (Apple Git-155)
Create a Working Directory
Create a folder to hold both the backend and frontend projects:
mkdir react-native-node-auth
cd react-native-node-auth
Your workspace will eventually look like this:
react-native-node-auth/
│
├── backend/
│
└── mobile/
Keeping both projects under the same parent directory makes development and version control more organized.
Install Postman (Optional)
Although not required, an API testing tool is highly recommended.
We'll use it to verify that:
- User registration works correctly.
- Login returns valid JWT tokens.
- Refresh tokens generate new access tokens.
- Protected routes require authentication.
- Logout revokes refresh tokens.
You can also use alternatives such as Thunder Client or Insomnia if you prefer.
Verify Everything Is Ready
Before continuing, confirm that the following commands work without errors:
node -v
npm -v
git --version
If you're using a local MongoDB installation, make sure the server is running:
mongod
If you're using MongoDB Atlas, ensure you have your connection string available.
Finally, verify that your Android emulator or iOS simulator starts successfully so you'll be ready to run the React Native app later in the tutorial.
At this point, your development environment is fully prepared, and you're ready to start building the backend authentication API.
Creating the Node.js Backend
Now that your development environment is ready, it's time to build the backend that will handle user authentication. In this section, you'll create a new Express 5 project, install the required dependencies, and organize the project structure for scalability.
By the end of this section, you'll have a running Express server connected to MongoDB and ready for implementing JWT authentication.
Step 1: Create the Backend Project
Navigate to your workspace created in the previous section:
cd react-native-node-auth
Create a new folder for the backend:
mkdir backend
cd backend
Initialize a new Node.js project:
npm init -y
This generates a default package.json file.
Step 2: Install Dependencies
Install the runtime dependencies:
npm install express mongoose bcrypt jsonwebtoken dotenv cors cookie-parser uuid
These packages provide the following functionality:
| Package | Purpose |
|---|---|
| express | Web framework for building REST APIs |
| mongoose | MongoDB Object Data Modeling (ODM) |
| bcrypt | Password hashing |
| jsonwebtoken | Create and verify JWTs |
| dotenv | Load environment variables |
| cors | Enable Cross-Origin Resource Sharing |
| cookie-parser | Parse cookies (optional, useful for web clients) |
| uuid | Generate unique refresh token identifiers |
Next, install the development dependencies:
npm install -D nodemon
nodemon automatically restarts the server whenever you make changes during development.
Step 3: Configure package.json
Update the scripts section to simplify running the application:
{
"scripts": {
"dev": "nodemon server.js",
"start": "node server.js"
}
}
Now you can start the development server with:
npm run dev
Step 4: Create the Project Structure
A well-organized project structure makes the application easier to maintain as it grows.
Create the following folders:
backend/
│
├── config/
├── controllers/
├── middleware/
├── models/
├── routes/
├── services/
├── utils/
├── .env
├── .gitignore
├── package.json
└── server.js
Create them quickly from the terminal:
mkdir config controllers middleware models routes services utils
touch server.js .env .gitignore
Folder Overview
| Folder | Purpose |
|---|---|
| config | Database and application configuration |
| controllers | Request handlers |
| middleware | Authentication middleware |
| models | Mongoose schemas |
| routes | Express routes |
| services | Business logic, such as token generation |
| utils | Helper functions |
Step 5: Create the Express Server
Open server.js and add the following code:
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const mongoose = require('mongoose');
const app = express();
app.use(cors());
app.use(express.json());
app.get('/', (req, res) => {
res.json({
success: true,
message: 'React Native JWT Authentication API'
});
});
const PORT = process.env.PORT || 3000;
mongoose
.connect(process.env.MONGO_URI)
.then(() => {
console.log('MongoDB connected');
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
})
.catch(err => console.error(err));
This server:
- Loads environment variables.
- Enables CORS.
- Parses JSON request bodies.
- Connects to MongoDB.
- Starts the Express server only after a successful database connection.
Step 6: Configure Environment Variables
Open the .env file and add:
PORT=3000
MONGO_URI=mongodb://localhost:27017/react_native_auth
JWT_ACCESS_SECRET=your-super-secret-access-key
JWT_REFRESH_SECRET=your-super-secret-refresh-key
ACCESS_TOKEN_EXPIRES=15m
REFRESH_TOKEN_EXPIRES=7d
Environment Variable Explanation
| Variable | Description |
|---|---|
| PORT | Express server port |
| MONGO_URI | MongoDB connection string |
| JWT_ACCESS_SECRET | Secret used to sign access tokens |
| JWT_REFRESH_SECRET | Secret used to sign refresh tokens |
| ACCESS_TOKEN_EXPIRES | Access token lifetime |
| REFRESH_TOKEN_EXPIRES | Refresh token lifetime |
Security Tip: Never commit your
.envfile to version control. Store secrets securely using environment variables in production.
Step 7: Ignore Sensitive Files
Open .gitignore and add:
node_modules
.env
This prevents dependencies and sensitive configuration from being committed to Git.
Step 8: Start the Server
Run:
npm run dev
If everything is configured correctly, you should see output similar to:
[nodemon] 3.1.14
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: js,mjs,cjs,json
[nodemon] starting `node server.js`
◇ injected env (6) from .env // tip: ⌘ override existing { override: true }
MongoDB connected
Server running on port 3000
Step 9: Test the API
Open your browser or use Postman and visit:
http://localhost:3000/
Expected response:
{
"success": true,
"message": "React Native JWT Authentication API"
}
This confirms that:
- Express is running.
- MongoDB is connected.
- JSON responses are working.
- The backend is ready for further development.
Current Project Structure
At this point, your backend project should look like this:
backend/
│
├── config/
├── controllers/
├── middleware/
├── models/
├── routes/
├── services/
├── utils/
├── node_modules/
├── .env
├── .gitignore
├── package.json
├── package-lock.json
└── server.js
Configuring MongoDB
With the Express server up and running, the next step is to configure MongoDB properly. While the previous section connected directly to the database from server.js, it's a better practice to move the database connection into a dedicated configuration module. This keeps the application organized and makes it easier to manage as the project grows.
In this section, you'll:
- Create a reusable MongoDB connection module
- Configure Mongoose connection options
- Improve error handling
- Refactor the Express server
- Verify the database connection
Why Use a Separate Database Configuration?
As your application grows, you'll likely have multiple configuration files for the database, authentication, logging, and other services. Separating the database logic keeps server.js clean and focused on starting the application.
Instead of this:
server.js
├── Express configuration
├── MongoDB connection
├── Routes
├── Middleware
└── Server startup
You'll have:
server.js
├── Express configuration
├── Middleware
├── Routes
└── Start server
config/
└── database.js
This modular approach improves readability and maintainability.
Step 1: Create the Database Configuration File
Create a new file:
backend/
└── config/
└── database.js
Add the following code:
const mongoose = require('mongoose');
const connectDatabase = async () => {
try {
const connection = await mongoose.connect(process.env.MONGO_URI);
console.log(
`MongoDB Connected: ${connection.connection.host}`
);
} catch (error) {
console.error('Database connection failed');
console.error(error.message);
process.exit(1);
}
};
module.exports = connectDatabase;
How It Works
The connectDatabase() function:
- Connects to MongoDB using the connection string stored in
.env - Prints the connected database host
- Stops the application if the connection fails
Failing fast during startup is generally better than running an application without a working database.
Step 2: Update server.js
Refactor server.js to use the new configuration module.
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const connectDatabase = require('./config/database');
const app = express();
connectDatabase();
app.use(cors());
app.use(express.json());
app.get('/', (req, res) => {
res.json({
success: true,
message: 'React Native JWT Authentication API'
});
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Notice how much cleaner the file becomes after moving the database logic out.
Step 3: Improve CORS Configuration
Instead of allowing every origin, it's better to configure CORS explicitly.
Replace:
app.use(cors());
with:
app.use(
cors({
origin: '*',
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization']
})
);
For development, origin: '*' is acceptable. In production, replace it with your application's domain or a list of trusted origins.
Step 4: Add Application-Level Middleware
Before adding authentication routes, configure the middleware your application will use.
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
This enables Express to parse:
- JSON request bodies
- URL-encoded form data
Step 5: Organize Environment Variables
Your .env file should now contain:
PORT=3000
MONGO_URI=mongodb://localhost:27017/react_native_auth
JWT_ACCESS_SECRET=your-super-secret-access-key
JWT_REFRESH_SECRET=your-super-secret-refresh-key
ACCESS_TOKEN_EXPIRES=15m
REFRESH_TOKEN_EXPIRES=7d
Recommended Production Secrets
For production, use long, randomly generated secrets instead of simple text strings.
Example:
JWT_ACCESS_SECRET=3d18ab2d5e8d4d56b1d4e9b2e76a...
JWT_REFRESH_SECRET=b71a81dbe42d73d...
Store these securely using your hosting provider's environment variable management rather than committing them to source control.
Step 6: Verify the Database Connection
Start the server:
npm run dev
You should see output similar to:
◇ injected env (6) from .env // tip: ⌘ override existing { override: true }
Server running on port 3000
MongoDB Connected: localhost
If you're using MongoDB Atlas, the hostname will resemble:
MongoDB Connected: cluster0-shard-00-00.xxxxx.mongodb.net
Troubleshooting Common Connection Issues
Error: ECONNREFUSED
This usually means the MongoDB server isn't running.
Start MongoDB locally:
mongod
Error: Authentication failed
Check that:
- The username and password in your connection string are correct.
- The database user has the necessary permissions.
- The connection string matches the one provided by MongoDB Atlas.
Error: IP Address Not Whitelisted
If you're using MongoDB Atlas:
- Add your current IP address to the Atlas Network Access list.
- Alternatively, allow access from all IPs (
0.0.0.0/0) for development purposes only.
Current Project Structure
After adding the database configuration, your project should look like this:
backend/
│
├── config/
│ └── database.js
│
├── controllers/
├── middleware/
├── models/
├── routes/
├── services/
├── utils/
│
├── .env
├── .gitignore
├── package.json
└── server.js
The backend is now configured with a clean, reusable MongoDB connection layer and is ready to define the application's data models.
Creating the User and Refresh Token Models
With MongoDB configured, it's time to define the database models that power the authentication system. We'll create two Mongoose models:
- User – Stores user account information such as name, email, and hashed password.
- RefreshToken – Stores refresh tokens issued to users, allowing secure token rotation and revocation.
Separating refresh tokens into their own collection makes it easier to manage multiple user sessions across different devices and invalidate tokens when needed.
Understanding the Data Model
Our authentication system uses two collections:
MongoDB
│
├── users
│ ├── _id
│ ├── name
│ ├── email
│ ├── password
│ ├── createdAt
│ └── updatedAt
│
└── refresh_tokens
├── _id
├── user
├── token
├── expiresAt
├── revoked
├── createdAt
└── updatedAt
This design supports:
- Multiple devices per user
- Token revocation
- Secure logout
- Refresh token rotation
- Session management
Step 1: Create the User Model
Create a new file:
models/
└── User.js
Add the following code:
const mongoose = require('mongoose');
const userSchema = new mongoose.Schema(
{
name: {
type: String,
required: true,
trim: true
},
email: {
type: String,
required: true,
unique: true,
lowercase: true,
trim: true
},
password: {
type: String,
required: true
}
},
{
timestamps: true
}
);
module.exports = mongoose.model('User', userSchema);
Schema Explanation
| Field | Description |
|---|---|
| name | User's full name |
| Unique email address | |
| password | Hashed password |
| timestamps | Automatically adds createdAt and updatedAt |
Notice that the password is not stored in plain text. It will be hashed before saving to the database.
Step 2: Automatically Hash Passwords
Instead of hashing passwords inside the controller, it's cleaner to let the model handle it using a Mongoose pre-save hook.
Update User.js:
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');
const userSchema = new mongoose.Schema(
{
name: {
type: String,
required: true,
trim: true
},
email: {
type: String,
required: true,
unique: true,
lowercase: true,
trim: true
},
password: {
type: String,
required: true
}
},
{
timestamps: true
}
);
userSchema.pre('save', async function () {
if (!this.isModified('password')) {
return;
}
this.password = await bcrypt.hash(this.password, 10);
});
module.exports = mongoose.model('User', userSchema);
Why Use a Pre-save Hook?
Every time a new user is created, or a password is updated:
- Mongoose checks whether the password has changed.
- If it did, the password is hashed automatically.
- The hashed password is stored in MongoDB.
This avoids accidentally storing plain-text passwords.
Step 3: Add Password Comparison Method
Users need to log in by comparing the entered password with the hashed password stored in the database.
Add this method below the pre('save') hook:
userSchema.methods.comparePassword = async function (
candidatePassword
) {
return bcrypt.compare(candidatePassword, this.password);
};
Now any User document can verify a password like this:
const isMatch = await user.comparePassword(password);
This keeps password verification encapsulated within the model.
Step 4: Create the Refresh Token Model
Create a new file:
models/
└── RefreshToken.js
Add the following code:
const mongoose = require('mongoose');
const refreshTokenSchema = new mongoose.Schema(
{
user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
token: {
type: String,
required: true,
unique: true
},
expiresAt: {
type: Date,
required: true
},
revoked: {
type: Boolean,
default: false
}
},
{
timestamps: true,
collection: 'refresh_tokens'
}
);
module.exports = mongoose.model(
'RefreshToken',
refreshTokenSchema
);
Refresh Token Schema Explanation
| Field | Purpose |
|---|---|
| user | Reference to the user |
| token | JWT refresh token |
| expiresAt | Expiration timestamp |
| revoked | Indicates whether the token has been invalidated |
| timestamps | Tracks creation and update times |
By storing refresh tokens in the database, you can invalidate individual sessions without affecting others.
Step 5: Add an Index for Automatic Cleanup (Optional)
MongoDB supports TTL (Time-To-Live) indexes, which automatically remove expired documents.
Add the following after the schema definition:
refreshTokenSchema.index(
{ expiresAt: 1 },
{ expireAfterSeconds: 0 }
);
When a refresh token reaches its expiresAt date, MongoDB will eventually delete it automatically.
Note: TTL cleanup runs periodically, so expired documents may not disappear immediately.
Step 6: Review the Database Relationships
The relationship between the collections is:
User
│
├── _id
├── name
├── email
└── password
│
│
▼
RefreshToken
│
├── user (ObjectId)
├── token
├── expiresAt
└── revoked
A single user can have multiple refresh tokens, allowing concurrent sessions on different devices or browsers.
Step 7: Current Project Structure
Your project should now look like this:
backend/
│
├── config/
│ └── database.js
│
├── controllers/
│
├── middleware/
│
├── models/
│ ├── User.js
│ └── RefreshToken.js
│
├── routes/
│
├── services/
│
├── utils/
│
├── .env
├── package.json
└── server.js
Best Practices
Here are a few best practices incorporated into the models:
- Passwords are always hashed before storage.
- Password comparison logic is encapsulated in the model.
- Emails are normalized to lowercase to avoid duplicate accounts.
- Refresh tokens are stored separately for better session management.
- Expired refresh tokens can be cleaned up automatically with a TTL index.
- Timestamps provide useful audit information for account and session management.
Creating Authentication Controllers
With the database models in place, it's time to implement the core authentication logic. The authentication controller will handle user registration, login, token refresh, logout, and returning the authenticated user's profile.
To keep the controller focused on request handling, we'll first create a dedicated token service responsible for generating JWT access and refresh tokens.
By the end of this section, you'll have a complete authentication controller that supports:
- User registration
- User login
- JWT access token generation
- Refresh token generation
- Token rotation
- Logout with refresh token revocation
- Getting the authenticated user's profile
Authentication Flow
The authentication process follows this sequence:
┌───────────────┐
│ React Native │
└──────┬────────┘
│
│ Login
▼
┌───────────────┐
│ Express API │
└──────┬────────┘
│
▼
Verify Email & Password
│
▼
Generate Access Token
Generate Refresh Token
│
▼
Store Refresh Token
│
▼
Return Tokens
Later, when the access token expires:
Access Token Expired
│
▼
Send Refresh Token
│
▼
Validate Refresh Token
│
▼
Issue New Access Token
(Optional: Rotate Refresh Token)
Step 1: Create the Token Service
Create a new file:
services/
└── tokenService.js
Add the following code:
const jwt = require('jsonwebtoken');
const generateAccessToken = (user) => {
return jwt.sign(
{
userId: user._id,
email: user.email
},
process.env.JWT_ACCESS_SECRET,
{
expiresIn: process.env.ACCESS_TOKEN_EXPIRES
}
);
};
const generateRefreshToken = (user) => {
return jwt.sign(
{
userId: user._id
},
process.env.JWT_REFRESH_SECRET,
{
expiresIn: process.env.REFRESH_TOKEN_EXPIRES
}
);
};
module.exports = {
generateAccessToken,
generateRefreshToken
};
This service centralizes token generation, making it easier to maintain and update.
Step 2: Create the Authentication Controller
Create:
controllers/
└── authController.js
Import the required modules:
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const RefreshToken = require('../models/RefreshToken');
const {
generateAccessToken,
generateRefreshToken
} = require('../services/tokenService');
Step 3: Register a New User
Add the registration controller:
exports.register = async (req, res) => {
try {
const { name, email, password } = req.body;
const existingUser = await User.findOne({ email });
if (existingUser) {
return res.status(409).json({
success: false,
message: 'Email already exists'
});
}
const user = await User.create({
name,
email,
password
});
res.status(201).json({
success: true,
message: 'User registered successfully'
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message
});
}
};
Notice that we don't manually hash the password—the User model's pre-save hook takes care of that automatically.
Step 4: Login
Next, implement user login:
exports.login = async (req, res) => {
try {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (
!user ||
!(await user.comparePassword(password))
) {
return res.status(401).json({
success: false,
message: 'Invalid email or password'
});
}
const accessToken = generateAccessToken(user);
const refreshToken = generateRefreshToken(user);
const decoded = jwt.decode(refreshToken);
await RefreshToken.create({
user: user._id,
token: refreshToken,
expiresAt: new Date(decoded.exp * 1000)
});
res.json({
success: true,
accessToken,
refreshToken,
user: {
id: user._id,
name: user.name,
email: user.email
}
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message
});
}
};
When login succeeds, the API:
- Verifies the email.
- Compares the password.
- Generates an access token.
- Generates a refresh token.
- Stores the refresh token in MongoDB.
- Returns both tokens to the mobile app.
Step 5: Refresh the Access Token
When the access token expires, the mobile app sends its refresh token.
exports.refresh = async (req, res) => {
try {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(401).json({
success: false,
message: 'Refresh token required'
});
}
const storedToken =
await RefreshToken.findOne({
token: refreshToken,
revoked: false
});
if (!storedToken) {
return res.status(401).json({
success: false,
message: 'Invalid refresh token'
});
}
const payload = jwt.verify(
refreshToken,
process.env.JWT_REFRESH_SECRET
);
const user = await User.findById(payload.userId);
const newAccessToken =
generateAccessToken(user);
res.json({
success: true,
accessToken: newAccessToken
});
} catch (error) {
res.status(401).json({
success: false,
message: 'Refresh token expired'
});
}
};
At this point, we're only issuing a new access token. In the next section, we'll enhance this flow with refresh token rotation for improved security.
Step 6: Logout
Logging out should invalidate the refresh token.
exports.logout = async (req, res) => {
try {
const { refreshToken } = req.body;
await RefreshToken.findOneAndUpdate(
{
token: refreshToken
},
{
revoked: true
}
);
res.json({
success: true,
message: 'Logged out successfully'
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message
});
}
};
Revoking the refresh token prevents it from being used to obtain new access tokens.
Step 7: Get the Current User
Authenticated users often need to retrieve their profile information.
exports.me = async (req, res) => {
try {
const user = await User.findById(
req.user.userId
).select('-password');
res.json({
success: true,
user
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message
});
}
};
The req.user object will be populated by the authentication middleware, which we'll implement in the next major section.
Current Controller Responsibilities
Your authController.js now provides five endpoints:
| Method | Purpose |
|---|---|
register() |
Create a new user |
login() |
Authenticate and issue tokens |
refresh() |
Issue a new access token |
logout() |
Revoke a refresh token |
me() |
Return the authenticated user's profile |
Testing Flow
After wiring up the routes (in the next section), the authentication lifecycle will look like this:
Register
│
▼
Login
│
▼
Access Token + Refresh Token
│
▼
Access Protected APIs
│
▼
Access Token Expires
│
▼
Refresh Endpoint
│
▼
New Access Token
│
▼
Continue Using the App
│
▼
Logout
│
▼
Refresh Token Revoked
Implementing JWT Authentication Middleware and Routes
Now that the authentication controller is complete, it's time to connect everything. In this section, you'll create middleware to verify JWT access tokens, define the authentication routes, and register them with the Express application.
By the end of this section, your backend will expose a complete authentication API that you can test using Postman or Thunder Client before integrating it with the React Native app.
Authentication Request Flow
The following diagram illustrates how requests move through the application:
Client
│
▼
Express Routes
│
┌──────────┴──────────┐
│ │
Public Routes Protected Routes
│ │
▼ ▼
Auth Controller JWT Authentication
│
▼
Authentication Controller
│
▼
MongoDB
Public routes, such as registration and login, are accessible without authentication, while protected routes require a valid access token.
Step 1: Create the Authentication Middleware
Create a new file:
middleware/
└── authMiddleware.js
Add the following code:
const jwt = require('jsonwebtoken');
module.exports = (req, res, next) => {
const authHeader = req.headers.authorization;
if (
!authHeader ||
!authHeader.startsWith('Bearer ')
) {
return res.status(401).json({
success: false,
message: 'Authorization token missing'
});
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(
token,
process.env.JWT_ACCESS_SECRET
);
req.user = decoded;
next();
} catch (error) {
return res.status(401).json({
success: false,
message: 'Invalid or expired access token'
});
}
};
How the Middleware Works
For every protected request:
- Read the
Authorizationheader. - Verify it starts with
Bearer. - Extract the JWT.
- Validate the token using the access token secret.
- Store the decoded payload in
req.user. - Continue to the next middleware or route handler.
If the token is invalid or expired, the request is rejected with an HTTP 401 response.
Step 2: Create Authentication Routes
Create a new file:
routes/
└── authRoutes.js
Import the controller and middleware:
const express = require('express');
const authController = require('../controllers/authController');
const authMiddleware = require('../middleware/authMiddleware');
const router = express.Router();
Step 3: Register the Routes
Add the authentication endpoints:
router.post('/register', authController.register);
router.post('/login', authController.login);
router.post('/refresh', authController.refresh);
router.post('/logout', authController.logout);
router.get(
'/me',
authMiddleware,
authController.me
);
module.exports = router;
Notice that only the /me endpoint uses the authentication middleware.
Step 4: Register Routes in Express
Open server.js.
Import the routes:
const authRoutes = require('./routes/authRoutes');
Then register them after the middleware:
app.use('/api/auth', authRoutes);
Your server.js should now resemble:
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const connectDatabase = require('./config/database');
const app = express();
const authRoutes = require('./routes/authRoutes');
connectDatabase();
app.use(
cors({
origin: '*',
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization']
})
);
app.use(express.json());
app.use('/api/auth', authRoutes);
app.get('/', (req, res) => {
res.json({
success: true,
message: 'React Native JWT Authentication API'
});
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Available API Endpoints
Your authentication API now exposes the following endpoints:
| Method | Endpoint | Authentication |
|---|---|---|
| POST | /api/auth/register |
No |
| POST | /api/auth/login |
No |
| POST | /api/auth/refresh |
No |
| POST | /api/auth/logout |
No |
| GET | /api/auth/me |
Yes |
API Request Examples
Register
POST
POST /api/auth/register
Request body:
{
"name": "John Doe",
"email": "[email protected]",
"password": "password123"
}
Successful response:
{
"success": true,
"message": "User registered successfully"
}
Login
POST
POST /api/auth/login
Request body:
{
"email": "[email protected]",
"password": "password123"
}
Response:
{
"success": true,
"accessToken": "...",
"refreshToken": "...",
"user": {
"id": "...",
"name": "John Doe",
"email": "[email protected]"
}
}
Get Current User
GET
GET /api/auth/me
Headers:
Authorization: Bearer YOUR_ACCESS_TOKEN
Response:
{
"success": true,
"user": {
"_id": "...",
"name": "John Doe",
"email": "[email protected]",
"createdAt": "...",
"updatedAt": "..."
}
}
Refresh Access Token
POST
POST /api/auth/refresh
Request body:
{
"refreshToken": "YOUR_REFRESH_TOKEN"
}
Response:
{
"success": true,
"accessToken": "NEW_ACCESS_TOKEN"
}
Logout
POST
POST /api/auth/logout
Request body:
{
"refreshToken": "YOUR_REFRESH_TOKEN"
}
Response:
{
"success": true,
"message": "Logged out successfully"
}
Current Backend Structure
Your backend project should now have the following structure:
backend/
│
├── config/
│ └── database.js
│
├── controllers/
│ └── authController.js
│
├── middleware/
│ └── authMiddleware.js
│
├── models/
│ ├── User.js
│ └── RefreshToken.js
│
├── routes/
│ └── authRoutes.js
│
├── services/
│ └── tokenService.js
│
├── utils/
│
├── .env
├── package.json
└── server.js
Security Improvements to Consider
The implementation in this section is suitable for learning and development, but a production-ready authentication system can be strengthened further by:
- Rotating refresh tokens each time they are used.
- Storing a unique token identifier (
jti) instead of the raw refresh token. - Hashing refresh tokens before saving them to the database.
- Limiting the number of active sessions per user.
- Recording device information (browser, platform, IP address) for each session.
- Applying rate limiting to authentication endpoints.
- Adding request validation using libraries such as
express-validatororzod.
We'll revisit some of these enhancements later in the tutorial when discussing production security best practices.
Testing the Authentication API
Before integrating the backend with the React Native application, it's important to verify that every authentication endpoint behaves as expected. Catching issues at the API level makes debugging much easier later when the mobile app is involved.
In this section, you'll test the complete authentication workflow using Postman (or Thunder Client if you prefer Visual Studio Code).
By the end of this section, you'll confirm that:
- User registration works
- Login returns valid JWT tokens
- Protected endpoints require authentication
- Access tokens are validated correctly
- Refresh tokens generate new access tokens
- Logout revokes the refresh token
Authentication Workflow
The complete authentication flow is illustrated below:
Register
│
▼
Login
│
▼
Access Token
Refresh Token
│
▼
Protected API
│
▼
Access Token Expires
│
▼
Refresh Token
│
▼
New Access Token
│
▼
Continue Using API
│
▼
Logout
Step 1: Start the Backend
Start MongoDB if you're running it locally.
Then start the Express server:
npm run dev
Expected output:
Server running on port 3000
MongoDB Connected: localhost
Step 2: Register a New User
Request
POST
http://localhost:3000/api/auth/register
Body:
{
"name": "John Doe",
"email": "[email protected]",
"password": "password123"
}
Expected Response
{
"success": true,
"message": "User registered successfully"
}
If you register the same email again, you should receive:
{
"success": false,
"message": "Email already exists"
}
This confirms that duplicate accounts are prevented.
Step 3: Login
Request
POST
http://localhost:3000/api/auth/login
Body:
{
"email": "[email protected]",
"password": "password123"
}
Expected Response
{
"success": true,
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI2YTQwYTJiMzIxNmMxMWQ1MzU0ZGU3NTIiLCJlbWFpbCI6ImpvaG5AZXhhbXBsZS5jb20iLCJpYXQiOjE3ODI2MjEwMzYsImV4cCI6MTc4MjYyMTkzNn0.nUEE_aAd6OzsT5RvYDLGN-KiShwQKK3v8_Py8IwV6LE",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI2YTQwYTJiMzIxNmMxMWQ1MzU0ZGU3NTIiLCJpYXQiOjE3ODI2MjEwMzYsImV4cCI6MTc4MzIyNTgzNn0.Jy06gzF2vTjx_BmJRjVSHKfo8jb31rVgatBFGl63iQg",
"user": {
"id": "6a40a2b3216c11d5354de752",
"name": "John Doe",
"email": "[email protected]"
}
}
Copy both tokens—you'll use them in the following tests.
Step 4: Access a Protected Route
Request:
GET
http://localhost:3000/api/auth/me
Headers:
Authorization: Bearer YOUR_ACCESS_TOKEN
Expected response:
{
"success": true,
"user": {
"_id": "6a40a2b3216c11d5354de752",
"name": "John Doe",
"email": "[email protected]",
"createdAt": "2026-06-28T04:27:31.439Z",
"updatedAt": "2026-06-28T04:27:31.439Z",
"__v": 0
}
}
This confirms:
- JWT verification works.
- Authentication middleware is functioning.
- User information is retrieved correctly.
Step 5: Test an Invalid Token
Replace the access token with an invalid value.
Example:
Authorization: Bearer invalid-token
Expected response:
{
"success": false,
"message": "Invalid or expired access token"
}
This verifies that unauthorized requests are rejected.
Step 6: Refresh the Access Token
Send the refresh token obtained during login.
Request:
POST http://localhost:3000/api/auth/refresh
Body:
{
"refreshToken": "YOUR_REFRESH_TOKEN"
}
Expected response:
{
"success": true,
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI2YTQwYTJiMzIxNmMxMWQ1MzU0ZGU3NTIiLCJlbWFpbCI6ImpvaG5AZXhhbXBsZS5jb20iLCJpYXQiOjE3ODI2MjEzMTUsImV4cCI6MTc4MjYyMjIxNX0.ze3BPOh8BRi4pAfY3YrTYXtfoxV8Jz2VYnKCzSH9gB0"
}
Copy the new access token.
Repeat the /me request using the new token.
It should work exactly like the previous access token.
Step 7: Logout
Request:
POST http://localhost:3000/api/auth/logout
Body:
{
"refreshToken": "YOUR_REFRESH_TOKEN"
}
Expected response:
{
"success": true,
"message": "Logged out successfully"
}
The refresh token is now marked as revoked in the database.
Step 8: Verify Logout
Attempt to refresh the access token again using the revoked refresh token.
POST /api/auth/refresh
Body:
{
"refreshToken": "YOUR_REFRESH_TOKEN"
}
Expected response:
{
"success": false,
"message": "Invalid refresh token"
}
This confirms that revoked tokens cannot be reused.
Inspect MongoDB
Open MongoDB Compass or use the MongoDB shell.
You should see two collections:
react_native_auth
│
├── users
└── refresh_tokens
Example users document:
{
"_id": "...",
"name": "John Doe",
"email": "[email protected]",
"password": "$2b$10$...",
"createdAt": "...",
"updatedAt": "..."
}
Notice that the password is stored as a bcrypt hash.
Example refresh_tokens document:
{
"_id": "...",
"user": "...",
"token": "...",
"expiresAt": "...",
"revoked": false,
"createdAt": "...",
"updatedAt": "..."
}
After logging out, the revoked field should be updated to:
{
"revoked": true
}
Common Issues
401 Unauthorized
Check that:
- The
Authorizationheader is present. - The header uses the format
Bearer <token>. - The access token has not expired.
500 Internal Server Error
Verify:
- MongoDB is running.
- The
.envconfiguration is correct. - The database connection is successful.
Invalid Signature
Ensure:
JWT_ACCESS_SECRETmatches the secret used to generate the token.JWT_REFRESH_SECRETmatches the refresh token secret.
Refresh Token Not Found
This usually indicates one of the following:
- The user has already logged out.
- The refresh token expired.
- The token is invalid or was never stored.
Complete Authentication Lifecycle
At this stage, your authentication API supports the following workflow:
Register
│
▼
Login
│
▼
Access Token
Refresh Token
│
▼
Protected APIs
│
▼
Access Token Expires
│
▼
Refresh Token Endpoint
│
▼
New Access Token
│
▼
Continue Working
│
▼
Logout
│
▼
Refresh Token Revoked
You've now verified that the backend authentication system functions correctly from end to end.
Creating the React Native Project
Now that the backend authentication API is fully functional and tested, it's time to build the React Native application. In this section, you'll create a new React Native project, install the required dependencies, and organize the project structure for implementing the authentication flow.
By the end of this section, you'll have a clean React Native project ready to communicate with the Express API.
Application Architecture
The React Native application will be organized into reusable modules that separate concerns such as screens, navigation, API communication, and authentication state.
React Native App
│
├── Navigation
│
├── Screens
│ ├── Login
│ ├── Register
│ └── Home
│
├── Context
│ └── Authentication
│
├── Services
│ └── Axios API Client
│
├── Storage
│ └── AsyncStorage
│
└── Components
This modular structure keeps the application scalable and maintainable.
Step 1: Create the React Native Project
Navigate back to your workspace:
cd ..
Create a new React Native project.
Using the React Native Community CLI:
npx @react-native-community/cli init mobile
Or, if you're using the latest React Native initialization command available at the time of writing, follow the official React Native documentation. Once complete, your workspace should look like this:
react-native-node-auth/
│
├── backend/
│
└── mobile/
Step 2: Run the Application
Move into the project:
cd mobile
Install iOS dependencies (macOS only):
cd ios
pod install
cd ..
Start the Metro bundler:
npm start
Run on Android:
npm run android
Run on iOS:
npm run ios
You should see the default React Native welcome screen.
Step 3: Install Navigation
Install React Navigation:
npm install @react-navigation/native
Install the required peer dependencies:
npm install react-native-screens react-native-safe-area-context
For stack navigation:
npm install @react-navigation/native-stack
Step 4: Install Authentication Dependencies
Install Axios for making HTTP requests:
npm install axios
Install AsyncStorage for persisting authentication data:
npm install @react-native-async-storage/async-storage
For generating unique IDs if needed later:
npm install react-native-get-random-values
Step 5: Install Native Dependencies
For iOS:
cd ios
pod install
cd ..
Step 6: Organize the Project Structure
Inside the mobile project, create the following folders:
src/
│
├── api/
│
├── components/
│
├── context/
│
├── navigation/
│
├── screens/
│
├── services/
│
├── utils/
│
└── hooks/
Your project structure should now look like this:
mobile/
│
├── android/
├── ios/
├── src/
│ ├── api/
│ ├── components/
│ ├── context/
│ ├── hooks/
│ ├── navigation/
│ ├── screens/
│ ├── services/
│ └── utils/
│
├── App.js
├── package.json
└── ...
Step 7: Configure the Backend URL
Create a new file:
src/api/config.ts
Add the following:
export const API_URL = 'http://localhost:3000/api';
Android Emulator
If you're using the Android Emulator, replace localhost with:
export const API_URL = 'http://10.0.2.2:3000/api';
Physical Android Device
Use your computer's local IP address:
export const API_URL = 'http://192.168.1.10:3000/api';
Make sure both the mobile device and your backend server are connected to the same network.
Step 8: Verify API Connectivity
Create a temporary test in App.js:
import { useEffect } from 'react';
import { StyleSheet, Text } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import axios from 'axios';
import { API_URL } from './src/api/config';
function App() {
useEffect(() => {
axios
.get(API_URL.replace('/api', ''))
.then(({ data }) => console.log(data))
.catch(console.error);
}, []);
return (
<SafeAreaView style={styles.container}>
<Text>React Native JWT Authentication</Text>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
});
export default App;
Run the application and check the Metro console.
If everything is configured correctly, you should see:
{
success: true,
message: "React Native JWT Authentication API"
}
This confirms that the mobile application can communicate with the backend.
Common Connection Issues
Network Error
If Axios reports a network error:
- Verify that the backend server is running.
- Check that the API URL matches your development environment.
- Ensure your emulator or device can access your computer's IP address.
Android Emulator
Remember:
localhost ❌
10.0.2.2 ✅
The Android Emulator maps 10.0.2.2 to your host machine.
Physical Device
Ensure:
- Your computer and mobile device are on the same Wi-Fi network.
- Any firewall allows incoming connections to the backend server.
- The backend is listening on an accessible interface (for example,
0.0.0.0if necessary).
Current Project Structure
At this point, your overall workspace should look like this:
react-native-node-auth/
│
├── backend/
│
└── mobile/
│
├── src/
│ ├── api/
│ ├── components/
│ ├── context/
│ ├── hooks/
│ ├── navigation/
│ ├── screens/
│ ├── services/
│ └── utils/
│
├── android/
├── ios/
├── App.js
└── package.json
The React Native project is now ready to implement the authentication flow.
Creating the Authentication Context
One of the challenges in authentication is sharing the user's login state across the entire application. Instead of passing authentication data through props, React's Context API provides a centralized solution for managing authentication.
In this section, you'll create an AuthContext that:
- Stores the authenticated user
- Stores access and refresh tokens
- Restores the user's session when the app starts
- Provides
login() - Provides
register() - Provides
logout() - Provides
refreshToken() - Exposes a custom
useAuth()hook
By centralizing authentication logic, every screen can easily determine whether a user is signed in.
Authentication Architecture
AuthProvider
│
┌─────────────────┼─────────────────┐
│ │ │
▼ ▼ ▼
LoginScreen RegisterScreen HomeScreen
│ │
└───────────────┬───────────────────┘
│
useAuth()
│
▼
Authentication API
│
▼
Express Backend
Step 1: Create the Authentication Types
Create a new folder:
src/types
Create:
src/types/auth.ts
export interface User {
id: string;
name: string;
email: string;
}
export interface LoginResponse {
success: boolean;
accessToken: string;
refreshToken: string;
user: User;
}
export interface AuthContextType {
user: User | null;
loading: boolean;
login: (
email: string,
password: string
) => Promise<void>;
register: (
name: string,
email: string,
password: string
) => Promise<void>;
logout: () => Promise<void>;
}
Using interfaces keeps the code strongly typed and improves IDE autocompletion.
Step 2: Create the Authentication Context
Create:
src/context/AuthContext.tsx
Start by importing the required modules:
import {
createContext,
useContext,
useEffect,
useState,
ReactNode,
} from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { AuthContextType, User } from '../types/auth';
Create the Context
export const AuthContext =
createContext<AuthContextType | undefined>(
undefined
);
Create Provider Props
interface Props {
children: ReactNode;
}
Create the Provider
export function AuthProvider({
children,
}: Props) {
const [user, setUser] = useState<User | null>(
null
);
const [loading, setLoading] = useState(true);
useEffect(() => {
restoreSession();
}, []);
When the application starts, it automatically attempts to restore the previous login session.
Step 3: Restore the Session
Add:
const restoreSession = async () => {
try {
const userJson =
await AsyncStorage.getItem('user');
if (userJson) {
setUser(JSON.parse(userJson));
}
} finally {
setLoading(false);
}
};
At this point, we're only restoring the user object. Later in the tutorial, we'll also restore the access and refresh tokens.
Step 4: Create Placeholder Authentication Methods
We'll connect these methods to the backend API in the next section.
const login = async (
email: string,
password: string
) => {};
const register = async (
name: string,
email: string,
password: string
) => {};
const logout = async () => {};
Step 5: Provide the Context
Return the provider:
return (
<AuthContext.Provider
value={{
user,
loading,
login,
register,
logout,
}}
>
{children}
</AuthContext.Provider>
);
Complete the component:
}
Step 6: Create a Custom Hook
Create:
src/hooks/useAuth.ts
import { useContext } from 'react';
import { AuthContext } from '../context/AuthContext';
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error(
'useAuth must be used inside AuthProvider'
);
}
return context;
}
This hook eliminates repetitive useContext() calls throughout the application.
Step 7: Wrap the Application
Update App.tsx:
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { AuthProvider } from './src/context/AuthContext';
import AppNavigator from './src/navigation/AppNavigator';
export default function App() {
return (
<SafeAreaProvider>
<AuthProvider>
<AppNavigator />
</AuthProvider>
</SafeAreaProvider>
);
}
Now every screen has access to the authentication state.
Current Authentication Flow
App Starts
│
▼
AuthProvider
│
▼
Restore Session
│
▼
AsyncStorage
│
▼
User Available?
│
┌───┴────┐
│ │
Yes No
│ │
▼ ▼
Home Login
Current Project Structure
src/
├── api/
│ └── config.ts
├── context/
│ └── AuthContext.tsx
├── hooks/
│ └── useAuth.ts
├── navigation/
├── screens/
├── types/
│ └── auth.ts
└── utils/
Why This Design?
Separating authentication into a dedicated provider offers several benefits:
- Single source of truth for authentication state.
- Type safety with shared interfaces.
- Easy access through the
useAuth()hook. - Simplified testing by isolating authentication logic.
- Scalability, allowing future enhancements such as biometric authentication, role-based authorization, or multi-factor authentication.
Creating the Axios API Client
The React Native application needs a reliable way to communicate with the authentication API. Instead of calling axios.get() or axios.post() throughout the application, you'll create a reusable API client with a centralized configuration.
In this section, you'll:
- Create a reusable Axios instance
- Configure the API base URL
- Define authentication endpoints
- Prepare request and response interceptors
- Create strongly typed API methods
By centralizing API communication, your code becomes cleaner, easier to maintain, and ready for features such as automatic token refresh.
Project Structure
src/
├── api/
│ ├── config.ts
│ ├── client.ts
│ └── authApi.ts
Step 1: Install Axios
If you haven't already:
npm install axios
Step 2: Configure the API URL
src/api/config.ts
export const API_URL =
__DEV__
? 'http://10.0.2.2:3000/api'
: 'https://your-api.example.com/api';
For iOS Simulator:
export const API_URL = 'http://localhost:3000/api';
For a physical Android device:
export const API_URL =
'http://192.168.1.100:3000/api';
Step 3: Create the Axios Client
Create:
src/api/client.ts
import axios from 'axios';
import { API_URL } from './config';
const api = axios.create({
baseURL: API_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
export default api;
This instance will be reused throughout the application.
Step 4: Create Authentication Types
Update src/types/auth.ts.
export interface LoginRequest {
email: string;
password: string;
}
export interface RegisterRequest {
name: string;
email: string;
password: string;
}
export interface User {
id: string;
name: string;
email: string;
}
export interface LoginResponse {
success: boolean;
accessToken: string;
refreshToken: string;
user: User;
}
Step 5: Create the Authentication API
Create:
src/api/authApi.ts
Import everything:
import api from './client';
import {
LoginRequest,
LoginResponse,
RegisterRequest,
} from '../types/auth';
Register
export const registerApi = (
data: RegisterRequest
) => {
return api.post('/auth/register', data);
};
Login
export const loginApi = async (
data: LoginRequest
): Promise<LoginResponse> => {
const response = await api.post<LoginResponse>(
'/auth/login',
data
);
return response.data;
};
Refresh Token
export const refreshTokenApi = async (
refreshToken: string
): Promise<RefreshResponse> => {
const response = await api.post<RefreshResponse>(
'/auth/refresh',
{
refreshToken,
}
);
return response.data;
};
Logout
export const logoutApi = (
refreshToken: string
) => {
return api.post('/auth/logout', {
refreshToken,
});
};
Current User
export const getCurrentUser = async () => {
const response = await api.get('/auth/me');
return response.data;
};
Notice that we don't manually attach the Authorization header. The Axios interceptor you'll build later will handle that automatically.
Step 6: Verify the API Client
Temporarily test the login API from App.tsx:
import { useEffect } from 'react';
import { login } from './src/api/authApi';
export default function App() {
useEffect(() => {
login({
email: '[email protected]',
password: 'password123',
})
.then(console.log)
.catch(console.error);
}, []);
return null;
}
If the backend is running and the credentials are valid, you should see output similar to:
{
success: true,
accessToken: "...",
refreshToken: "...",
user: {
id: "...",
name: "John Doe",
email: "[email protected]"
}
}
Current API Layer
Your API layer should now look like this:
src/api/
├── authApi.ts
├── client.ts
└── config.ts
This separation gives each file a clear responsibility:
| File | Responsibility |
|---|---|
config.ts |
Stores the API base URL. |
client.ts |
Creates and configures the shared Axios instance. |
authApi.ts |
Exposes authentication-related API methods. |
As the application grows, you can add additional modules such as userApi.ts, productApi.ts, or orderApi.ts without modifying the shared Axios configuration.
Why Use an Axios Instance?
Creating a shared Axios instance offers several advantages:
- A single place to configure the base URL and default headers.
- Consistent request timeouts across the application.
- Easy addition of interceptors for attaching access tokens and refreshing expired ones.
- Cleaner API modules that focus on endpoints rather than configuration.
- Improved maintainability as your application scales.
Completing the Authentication Context
Now that the API layer is complete, it's time to connect it with the authentication context. This is where the React Native application becomes fully functional.
In this section, you'll implement:
- User login
- User registration
- User logout
- Session restoration
- Token persistence
- Authentication state management
After completing this section, every screen in the application can access the authenticated user through a single useAuth() hook.
Authentication Lifecycle
App Launch
│
▼
Restore Session
│
┌───────────────┴───────────────┐
│ │
▼ ▼
Tokens Found No Tokens
│ │
▼ ▼
Restore User Login Screen
│
▼
Authenticated App
Step 1: Update the Context Types
Update src/types/auth.ts.
export interface AuthContextType {
user: User | null;
loading: boolean;
isAuthenticated: boolean;
login(
email: string,
password: string
): Promise<void>;
register(
name: string,
email: string,
password: string
): Promise<void>;
logout(): Promise<void>;
}
Adding isAuthenticated makes navigation much simpler.
Step 2: Import Required Modules
Open AuthContext.tsx.
import {
createContext,
useEffect,
useState,
ReactNode,
} from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import {
login as loginApi,
register as registerApi,
logout as logoutApi,
} from '../api/authApi';
import {
AuthContextType,
User,
} from '../types/auth';
import { STORAGE_KEYS } from '../constants/storage';
Step 3: Create Authentication State
const [user, setUser] =
useState<User | null>(null);
const [loading, setLoading] =
useState(true);
const isAuthenticated = !!user;
Step 4: Restore the Session
Replace the placeholder function with:
const restoreSession = async () => {
try {
const [
userJson,
accessToken,
refreshToken,
] = await Promise.all([
AsyncStorage.getItem(STORAGE_KEYS.USER),
AsyncStorage.getItem(
STORAGE_KEYS.ACCESS_TOKEN
),
AsyncStorage.getItem(
STORAGE_KEYS.REFRESH_TOKEN
),
]);
if (
userJson &&
accessToken &&
refreshToken
) {
setUser(JSON.parse(userJson));
}
} finally {
setLoading(false);
}
};
Notice that we only restore the user if all authentication data exists.
Step 5: Implement Login
Replace the placeholder with:
const login = async (
email: string,
password: string
) => {
const response = await loginApi({
email,
password,
});
await Promise.all([
AsyncStorage.setItem(
STORAGE_KEYS.USER,
JSON.stringify(response.user)
),
AsyncStorage.setItem(
STORAGE_KEYS.ACCESS_TOKEN,
response.accessToken
),
AsyncStorage.setItem(
STORAGE_KEYS.REFRESH_TOKEN,
response.refreshToken
),
]);
setUser(response.user);
};
The login process now:
- Calls the backend.
- Receives both JWTs.
- Saves them locally.
- Updates React state.
Step 6: Implement Registration
const register = async (
name: string,
email: string,
password: string
) => {
await registerApi({
name,
email,
password,
});
await login(email, password);
};
A common user experience is to automatically log the user in immediately after successful registration.
Step 7: Implement Logout
const logout = async () => {
try {
const refreshToken =
await AsyncStorage.getItem(
STORAGE_KEYS.REFRESH_TOKEN
);
if (refreshToken) {
await logoutApi(refreshToken);
}
} finally {
await AsyncStorage.multiRemove([
STORAGE_KEYS.USER,
STORAGE_KEYS.ACCESS_TOKEN,
STORAGE_KEYS.REFRESH_TOKEN,
]);
setUser(null);
}
};
Even if the backend request fails, local authentication data is still cleared to ensure the user is logged out on the device.
Step 8: Provide the Context
Update the provider value:
<AuthContext.Provider
value={{
user,
loading,
isAuthenticated,
login,
register,
logout,
}}
>
{children}
</AuthContext.Provider>
Complete Authentication Flow
At this point, the application supports the following lifecycle:
Launch App
│
▼
Restore Session
│
▼
Login
│
▼
Store JWT Tokens
│
▼
Navigate to Home
│
▼
Authenticated Requests
│
▼
Logout
│
▼
Clear Storage
Why Save the User Object?
Some developers only store the JWT and fetch the user's profile every time the app launches. In this tutorial, we also persist the user object because it:
- Improves startup performance.
- Reduces unnecessary API requests.
- Provides immediate access to the user's name and email.
- Creates a smoother user experience while the app initializes.
When needed, the app can later synchronize the stored profile with the backend using the /auth/me endpoint.
Current Project Structure
src/
├── api/
│ ├── authApi.ts
│ ├── client.ts
│ └── config.ts
├── constants/
│ └── storage.ts
├── context/
│ └── AuthContext.tsx
├── hooks/
│ └── useAuth.ts
├── navigation/
├── screens/
├── types/
│ └── auth.ts
└── utils/
The authentication layer is now complete. It manages user state, persists authentication data, restores sessions, and exposes a simple API for the rest of the application.
Creating the Navigation Structure
Navigation is responsible for directing users to the appropriate screens based on their authentication status. Instead of manually navigating after every login or logout, you'll create a navigation structure that automatically responds to changes in the authentication state.
By the end of this section, your application will:
- Display the login flow for unauthenticated users.
- Display the main application for authenticated users.
- Automatically switch between the two navigation stacks when the authentication state changes.
- Be ready for additional protected screens in future sections.
Navigation Architecture
The application will use the following navigation hierarchy:
NavigationContainer
│
AppNavigator
│
┌────────────┴────────────┐
│ │
▼ ▼
Auth Stack App Stack
│ │
┌───────┴────────┐ ┌──────┴───────┐
│ │ │ │
▼ ▼ ▼ ▼
LoginScreen RegisterScreen HomeScreen ProfileScreen
The AppNavigator acts as the entry point and decides which stack to render based on the authentication state.
Step 1: Create the Navigation Folder
Your navigation folder should now look like this:
src/navigation/
├── AppNavigator.tsx
├── AuthNavigator.tsx
└── MainNavigator.tsx
Step 2: Create the Authentication Navigator
Create:
src/navigation/AuthNavigator.tsx
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import LoginScreen from '../screens/LoginScreen';
import RegisterScreen from '../screens/RegisterScreen';
export type AuthStackParamList = {
Login: undefined;
Register: undefined;
};
const Stack = createNativeStackNavigator<AuthStackParamList>();
export default function AuthNavigator() {
return (
<Stack.Navigator
screenOptions={{
headerShown: false,
}}
>
<Stack.Screen
name="Login"
component={LoginScreen}
/>
<Stack.Screen
name="Register"
component={RegisterScreen}
/>
</Stack.Navigator>
);
}
This stack contains all screens that are accessible before the user signs in.
Step 3: Create the Main Application Navigator
Create:
src/navigation/MainNavigator.tsx
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import HomeScreen from '../screens/HomeScreen';
export type MainStackParamList = {
Home: undefined;
};
const Stack = createNativeStackNavigator<MainStackParamList>();
export default function MainNavigator() {
return (
<Stack.Navigator>
<Stack.Screen
name="Home"
component={HomeScreen}
options={{
title: 'Home',
}}
/>
</Stack.Navigator>
);
}
As the application grows, you can add additional protected screens, such as:
- Profile
- Settings
- Notifications
- Orders
- Favorites
without affecting the authentication flow.
Step 4: Create the Root Navigator
Create:
src/navigation/AppNavigator.tsx
import { NavigationContainer } from '@react-navigation/native';
import AuthNavigator from './AuthNavigator';
import MainNavigator from './MainNavigator';
import { useAuth } from '../hooks/useAuth';
export default function AppNavigator() {
const {
loading,
isAuthenticated,
} = useAuth();
if (loading) {
return null;
}
return (
<NavigationContainer>
{isAuthenticated ? (
<MainNavigator />
) : (
<AuthNavigator />
)}
</NavigationContainer>
);
}
This component is the heart of the navigation system.
Whenever isAuthenticated changes:
false → Auth Stack
true → Main Stack
React Navigation automatically replaces the current navigation tree.
Step 5: Update App.tsx
If you followed the previous section, your App.tsx should already look similar to this:
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { AuthProvider } from './src/context/AuthContext';
import AppNavigator from './src/navigation/AppNavigator';
export default function App() {
return (
<SafeAreaProvider>
<AuthProvider>
<AppNavigator />
</AuthProvider>
</SafeAreaProvider>
);
}
No further changes are required.
Step 6: Create Placeholder Screens
To verify the navigation flow, create three placeholder screens.
LoginScreen.tsx
import { Text } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
export default function LoginScreen() {
return (
<SafeAreaView
style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
}}
>
<Text>Login Screen</Text>
</SafeAreaView>
);
}
RegisterScreen.tsx
import { Text } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
export default function RegisterScreen() {
return (
<SafeAreaView
style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
}}
>
<Text>Register Screen</Text>
</SafeAreaView>
);
}
HomeScreen.tsx
import { Text } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
export default function HomeScreen() {
return (
<SafeAreaView
style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
}}
>
<Text>Home Screen</Text>
</SafeAreaView>
);
}
These placeholders allow you to verify that the navigation structure works before adding forms and business logic.
Navigation Flow
The application now follows this simple authentication-aware flow:
App Launch
│
▼
AuthProvider
│
▼
Restore Session
│
▼
isAuthenticated?
│
┌───┴──────────┐
│ │
▼ ▼
Auth Stack Main Stack
│ │
▼ ▼
Login Home
This approach keeps authentication logic separate from navigation logic, making the codebase easier to understand and maintain.
Why Use Separate Navigation Stacks?
Using distinct navigators for authenticated and unauthenticated users provides several advantages:
- It prevents unauthorized access to protected screens.
- It simplifies navigation after login or logout—there's no need to manually reset the navigation stack.
- It scales well as your application grows with additional features.
- It follows a common architecture used in production React Native applications.
Building the Login Screen
With the authentication context and navigation in place, it's time to create the first screen users interact with—the login screen.
In this section, you'll build a modern login form that:
- Uses React Hook Form for form management.
- Validates input with Zod.
- Calls the
login()method fromAuthContext. - Displays validation and authentication errors.
- Shows a loading indicator while signing in.
- Navigates automatically to the authenticated part of the app upon successful login.
By the end of this section, users will be able to sign in with the credentials created earlier through the Node.js backend.
Login Screen Flow
User
│
▼
Enter Email & Password
│
▼
Client-side Validation
│
▼
AuthContext.login()
│
▼
Node.js API
│
▼
Access Token + Refresh Token
│
▼
Save Tokens
│
▼
Home Screen
Step 1: Install Form Dependencies
If you haven't already:
npm install react-hook-form zod @hookform/resolvers
Step 2: Create the Validation Schema
Create:
src/validation/loginSchema.ts
import { z } from 'zod';
export const loginSchema = z.object({
email: z
.email('Please enter a valid email address'),
password: z
.string()
.min(6, 'Password must be at least 6 characters'),
});
export type LoginFormData = z.infer<typeof loginSchema>;
This schema ensures:
- A valid email address is entered.
- The password contains at least six characters.
Step 3: Create the Login Screen
Replace the placeholder LoginScreen.tsx with the following implementation.
import { useState } from 'react';
import {
ActivityIndicator,
Button,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Controller, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useAuth } from '../hooks/useAuth';
import { loginSchema, LoginFormData } from '../validation/loginSchema';
import type { AuthStackParamList } from '../navigation/AuthNavigator';
type NavigationProp = NativeStackNavigationProp<
AuthStackParamList,
'Login'
>;
export default function LoginScreen() {
const navigation = useNavigation<NavigationProp>();
const { login } = useAuth();
const [serverError, setServerError] = useState('');
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
defaultValues: {
email: '',
password: '',
},
});
const onSubmit = async (data: LoginFormData) => {
setServerError('');
try {
await login(data.email, data.password);
} catch (error: any) {
setServerError(
error.response?.data?.message ??
'Unable to sign in.'
);
}
};
return (
<SafeAreaView style={styles.container}>
<Text style={styles.title}>
Welcome Back
</Text>
<Controller
control={control}
name="email"
render={({ field }) => (
<TextInput
style={styles.input}
placeholder="Email"
autoCapitalize="none"
keyboardType="email-address"
value={field.value}
onChangeText={field.onChange}
/>
)}
/>
{errors.email && (
<Text style={styles.error}>
{errors.email.message}
</Text>
)}
<Controller
control={control}
name="password"
render={({ field }) => (
<TextInput
style={styles.input}
placeholder="Password"
secureTextEntry
value={field.value}
onChangeText={field.onChange}
/>
)}
/>
{errors.password && (
<Text style={styles.error}>
{errors.password.message}
</Text>
)}
{serverError ? (
<Text style={styles.error}>
{serverError}
</Text>
) : null}
{isSubmitting ? (
<ActivityIndicator />
) : (
<Button
title="Sign In"
onPress={handleSubmit(onSubmit)}
/>
)}
<View style={styles.footer}>
<Button
title="Create Account"
onPress={() =>
navigation.navigate('Register')
}
/>
</View>
</SafeAreaView>
);
}
Step 4: Add Basic Styles
At the bottom of LoginScreen.tsx, add:
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 24,
justifyContent: 'center',
},
title: {
fontSize: 30,
fontWeight: '700',
marginBottom: 32,
textAlign: 'center',
},
input: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 8,
paddingHorizontal: 16,
paddingVertical: 12,
marginBottom: 12,
},
error: {
color: '#dc2626',
marginBottom: 12,
},
footer: {
marginTop: 16,
},
});
Note: These styles are intentionally simple. In a production app, you might use a design system or UI library such as React Native Paper, NativeWind, or Tamagui for a more polished interface.
How It Works
When the user taps Sign In:
- React Hook Form validates the input using the Zod schema.
- If validation succeeds, the
login()method fromAuthContextis called. AuthContextinvokes the Node.js API and stores the returned access and refresh tokens in AsyncStorage.- The authenticated user is saved in context.
isAuthenticatedbecomestrue.AppNavigatorautomatically switches from the authentication stack to the main application stack.
No explicit navigation is required after a successful login because the navigation tree reacts to the authentication state.
Current Project Structure
src/
├── api/
├── constants/
├── context/
├── hooks/
├── navigation/
├── screens/
│ ├── HomeScreen.tsx
│ ├── LoginScreen.tsx
│ └── RegisterScreen.tsx
├── types/
├── validation/
│ └── loginSchema.ts
└── utils/
The login experience is now fully integrated with your authentication context and backend API.
Building the Registration Screen
The registration screen allows new users to create an account. Like the login screen, it uses React Hook Form and Zod for validation, ensuring consistent behavior and a clean user experience.
In this section, you'll:
- Build a registration form.
- Validate user input.
- Submit registration requests to the Node.js backend.
- Automatically sign in after successful registration.
- Display validation and server-side errors.
By the end of this section, users will be able to create an account and immediately access the authenticated area of the application.
Registration Flow
User
│
▼
Registration Form
│
▼
Validate Input
│
▼
POST /auth/register
│
▼
User Created
│
▼
Automatic Login
│
▼
Store JWT Tokens
│
▼
Home Screen
Step 1: Create the Validation Schema
Create:
src/validation/registerSchema.ts
import { z } from 'zod';
export const registerSchema = z
.object({
name: z
.string()
.min(2, 'Name must be at least 2 characters'),
email: z
.email('Please enter a valid email address'),
password: z
.string()
.min(6, 'Password must be at least 6 characters'),
confirmPassword: z.string(),
})
.refine(
data => data.password === data.confirmPassword,
{
path: ['confirmPassword'],
message: 'Passwords do not match',
}
);
export type RegisterFormData =
z.infer<typeof registerSchema>;
Unlike the login form, the registration form includes a confirmPassword field to reduce the chance of users mistyping their password.
Step 2: Replace the Placeholder Screen
Open:
src/screens/RegisterScreen.tsx
Replace its contents with:
import { useState } from 'react';
import {
ActivityIndicator,
Button,
StyleSheet,
Text,
TextInput,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Controller, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { registerSchema, RegisterFormData } from '../validation/registerSchema';
import { useAuth } from '../hooks/useAuth';
import type { AuthStackParamList } from '../navigation/AuthNavigator';
type NavigationProp = NativeStackNavigationProp<
AuthStackParamList,
'Register'
>;
export default function RegisterScreen() {
const navigation = useNavigation<NavigationProp>();
const { register } = useAuth();
const [serverError, setServerError] =
useState('');
const {
control,
handleSubmit,
formState: {
errors,
isSubmitting,
},
} = useForm<RegisterFormData>({
resolver: zodResolver(registerSchema),
defaultValues: {
name: '',
email: '',
password: '',
confirmPassword: '',
},
});
const onSubmit = async (
data: RegisterFormData
) => {
setServerError('');
try {
await register(
data.name,
data.email,
data.password
);
} catch (error: any) {
setServerError(
error.response?.data?.message ??
'Registration failed.'
);
}
};
return (
<SafeAreaView style={styles.container}>
<Text style={styles.title}>
Create Account
</Text>
<Controller
control={control}
name="name"
render={({ field }) => (
<TextInput
style={styles.input}
placeholder="Full Name"
value={field.value}
onChangeText={field.onChange}
/>
)}
/>
{errors.name && (
<Text style={styles.error}>
{errors.name.message}
</Text>
)}
<Controller
control={control}
name="email"
render={({ field }) => (
<TextInput
style={styles.input}
placeholder="Email"
keyboardType="email-address"
autoCapitalize="none"
value={field.value}
onChangeText={field.onChange}
/>
)}
/>
{errors.email && (
<Text style={styles.error}>
{errors.email.message}
</Text>
)}
<Controller
control={control}
name="password"
render={({ field }) => (
<TextInput
style={styles.input}
placeholder="Password"
secureTextEntry
value={field.value}
onChangeText={field.onChange}
/>
)}
/>
{errors.password && (
<Text style={styles.error}>
{errors.password.message}
</Text>
)}
<Controller
control={control}
name="confirmPassword"
render={({ field }) => (
<TextInput
style={styles.input}
placeholder="Confirm Password"
secureTextEntry
value={field.value}
onChangeText={field.onChange}
/>
)}
/>
{errors.confirmPassword && (
<Text style={styles.error}>
{errors.confirmPassword.message}
</Text>
)}
{serverError ? (
<Text style={styles.error}>
{serverError}
</Text>
) : null}
{isSubmitting ? (
<ActivityIndicator />
) : (
<Button
title="Create Account"
onPress={handleSubmit(onSubmit)}
/>
)}
<Button
title="Already have an account?"
onPress={() =>
navigation.goBack()
}
/>
</SafeAreaView>
);
}
Step 3: Reuse the Styles
To avoid duplication, create a shared style file.
Create:
src/styles/authStyles.ts
import { StyleSheet } from 'react-native';
export default StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
padding: 24,
},
title: {
fontSize: 30,
fontWeight: '700',
marginBottom: 32,
textAlign: 'center',
},
input: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 8,
paddingHorizontal: 16,
paddingVertical: 12,
marginBottom: 12,
},
error: {
color: '#dc2626',
marginBottom: 12,
},
});
Then, in both LoginScreen.tsx and RegisterScreen.tsx, replace the local StyleSheet.create(...) with:
import styles from '../styles/authStyles';
This eliminates duplicated styling and makes future design updates easier.
Registration Workflow
When the user taps Create Account:
- React Hook Form validates the input using the Zod schema.
- The form data is submitted to the
/auth/registerendpoint. - If registration succeeds,
AuthContext.register()automatically signs the user in. - The access token, refresh token, and user profile are stored in AsyncStorage.
- The authentication state updates, causing
AppNavigatorto switch from the authentication stack to the main application stack.
Current Project Structure
src/
├── api/
├── constants/
├── context/
├── hooks/
├── navigation/
├── screens/
│ ├── HomeScreen.tsx
│ ├── LoginScreen.tsx
│ └── RegisterScreen.tsx
├── styles/
│ └── authStyles.ts
├── types/
├── validation/
│ ├── loginSchema.ts
│ └── registerSchema.ts
└── utils/
At this point, your React Native application supports a complete authentication flow: users can register, log in, persist their session, and navigate seamlessly between authenticated and unauthenticated areas.
Building the Home Screen and Accessing Protected APIs
Now that users can register and sign in, it's time to create the authenticated area of the application. The home screen will display information about the currently signed-in user, provide a logout action, and demonstrate how to call protected backend endpoints.
In this section, you'll:
- Build the authenticated home screen.
- Display the user's profile information.
- Create a protected API endpoint on the backend.
- Attach the JWT access token to outgoing requests.
- Retrieve protected data from the backend.
- Log the user out.
By the end of this section, your React Native application will be able to communicate securely with protected Express routes.
Protected Request Flow
Home Screen
│
▼
Axios Request
│
▼
Authorization Header
│
▼
Express Middleware
│
▼
JWT Verification
│
▼
Protected Resource
Step 1: Create a Protected Route
Before consuming protected APIs from React Native, let's add a sample endpoint to the backend.
Create:
controllers/userController.js
exports.profile = async (req, res) => {
res.json({
success: true,
message: 'Protected endpoint',
user: req.user,
});
};
Create:
routes/userRoutes.js
const express = require('express');
const authMiddleware = require('../middleware/authMiddleware');
const {
profile,
} = require('../controllers/userController');
const router = express.Router();
router.get(
'/profile',
authMiddleware,
profile
);
module.exports = router;
Register the routes in server.js:
const userRoutes =
require('./routes/userRoutes');
app.use('/api/users', userRoutes);
Test the Endpoint
GET /api/users/profile
Headers:
Authorization: Bearer ACCESS_TOKEN
Expected response:
{
"success": true,
"message": "Protected endpoint",
"user": {
"userId": "...",
"email": "[email protected]",
"iat": 1740000000,
"exp": 1740000900
}
}
Step 2: Attach the Access Token
Open:
src/api/client.ts
Update the Axios client.
import axios from 'axios';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { API_URL } from './config';
import { STORAGE_KEYS } from '../constants/storage';
const api = axios.create({
baseURL: API_URL,
headers: {
'Content-Type': 'application/json',
},
});
Add a request interceptor.
api.interceptors.request.use(
async config => {
const token =
await AsyncStorage.getItem(
STORAGE_KEYS.ACCESS_TOKEN
);
if (token) {
config.headers.Authorization =
`Bearer ${token}`;
}
return config;
}
);
Every request now automatically includes:
Authorization: Bearer eyJhb...
No screen needs to manually attach the token.
Step 3: Create the User API
Create:
src/api/userApi.ts
import api from './client';
export const getProfile = async () => {
const response =
await api.get('/users/profile');
return response.data;
};
Step 4: Build the Home Screen
Replace the placeholder screen.
import { useEffect, useState } from 'react';
import {
ActivityIndicator,
Button,
ScrollView,
Text,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useAuth } from '../hooks/useAuth';
import { getProfile } from '../api/userApi';
Inside the component:
const { user, logout } = useAuth();
const [profile, setProfile] =
useState<any>(null);
const [loading, setLoading] =
useState(true);
Load the profile.
useEffect(() => {
loadProfile();
}, []);
const loadProfile = async () => {
try {
const data =
await getProfile();
setProfile(data.user);
} finally {
setLoading(false);
}
};
Render.
return (
<SafeAreaView
style={{
flex: 1,
}}
>
<ScrollView
contentContainerStyle={{
padding: 24,
}}
>
<Text
style={{
fontSize: 28,
fontWeight: '700',
}}
>
Welcome
</Text>
<Text>{user?.name}</Text>
<Text>{user?.email}</Text>
{loading ? (
<ActivityIndicator />
) : (
<>
<Text>
JWT Payload
</Text>
<Text>
{JSON.stringify(
profile,
null,
2
)}
</Text>
</>
)}
<Button
title="Logout"
onPress={logout}
/>
</ScrollView>
</SafeAreaView>
);
What Happens Behind the Scenes?
When getProfile() executes:
HomeScreen
│
▼
userApi.ts
│
▼
Axios Instance
│
▼
Request Interceptor
│
▼
Read Access Token
│
▼
Authorization Header
│
▼
Express Middleware
│
▼
JWT Verified
│
▼
Protected Response
The Home screen doesn't need to know anything about JWTs or authorization headers. That complexity is handled entirely by the shared Axios client.
Current Project Structure
src/
├── api/
│ ├── authApi.ts
│ ├── client.ts
│ ├── config.ts
│ └── userApi.ts
├── constants/
├── context/
├── hooks/
├── navigation/
├── screens/
│ ├── HomeScreen.tsx
│ ├── LoginScreen.tsx
│ └── RegisterScreen.tsx
├── styles/
├── types/
├── validation/
└── utils/
At this stage, your application supports authenticated API requests. Every outgoing request automatically includes the JWT access token, allowing the backend to authorize access to protected resources without additional code in your screens.
Implementing Automatic JWT Refresh with Axios Interceptors
One of the biggest advantages of JWT authentication is that users can remain signed in without repeatedly entering their credentials. However, access tokens should be short-lived to minimize the impact of token theft. When an access token expires, the application must transparently obtain a new one using a refresh token.
In this section, you'll implement an automatic refresh mechanism using Axios interceptors. The application will detect expired access tokens, request new tokens from the backend, update local storage, and retry the original request—all without interrupting the user.
By the end of this section, your React Native app will have a production-style token refresh flow.
Automatic Refresh Flow
API Request
│
▼
Access Token Attached
│
▼
Backend
│
▼
401 Unauthorized?
│
┌───┴──────────┐
│ │
No Yes
│ │
▼ ▼
Return Data Refresh Token
│
▼
New Access Token
New Refresh Token
│
▼
Save New Tokens
│
▼
Retry Original Request
│
▼
Return Response
Step 1: Update the Refresh API
Open:
src/api/authApi.ts
Update the refresh function to return typed data:
export interface RefreshResponse {
success: boolean;
accessToken: string;
refreshToken: string;
}
export const refreshToken = async (
refreshToken: string
): Promise<RefreshResponse> => {
const response = await api.post<RefreshResponse>(
'/auth/refresh',
{
refreshToken,
}
);
return response.data;
};
Step 2: Create Token Utilities
Create:
src/utils/tokenStorage.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
import { STORAGE_KEYS } from '../constants/storage';
export async function getAccessToken() {
return AsyncStorage.getItem(
STORAGE_KEYS.ACCESS_TOKEN
);
}
export async function getRefreshToken() {
return AsyncStorage.getItem(
STORAGE_KEYS.REFRESH_TOKEN
);
}
export async function saveTokens(
accessToken: string,
refreshToken: string
) {
await AsyncStorage.multiSet([
[STORAGE_KEYS.ACCESS_TOKEN, accessToken],
[STORAGE_KEYS.REFRESH_TOKEN, refreshToken],
]);
}
export async function clearTokens() {
await AsyncStorage.multiRemove([
STORAGE_KEYS.ACCESS_TOKEN,
STORAGE_KEYS.REFRESH_TOKEN,
STORAGE_KEYS.USER,
]);
}
Centralizing token operations reduces duplication and makes future changes (for example, switching to secure storage) much easier.
Step 3: Extend Axios Request Configuration
Create a custom interface to mark retried requests:
interface RetryRequestConfig {
_retry?: boolean;
}
This flag prevents infinite refresh loops if the refresh request itself fails.
Step 4: Add the Response Interceptor
Open:
src/api/client.ts
Below the request interceptor, add:
import type { AxiosError, InternalAxiosRequestConfig } from 'axios';
import {
getRefreshToken,
saveTokens,
clearTokens,
} from '../utils/tokenStorage';
import { refreshToken as refreshTokenApi } from './authApi';
interface RetryConfig extends InternalAxiosRequestConfig {
_retry?: boolean;
}
api.interceptors.response.use(
response => response,
async (error: AxiosError) => {
const originalRequest = error.config as RetryConfig;
if (
error.response?.status !== 401 ||
originalRequest._retry
) {
return Promise.reject(error);
}
originalRequest._retry = true;
try {
const refreshToken =
await getRefreshToken();
if (!refreshToken) {
throw new Error('Missing refresh token');
}
const response =
await refreshTokenApi(refreshToken);
await saveTokens(
response.accessToken,
response.refreshToken
);
originalRequest.headers.Authorization =
`Bearer ${response.accessToken}`;
return api(originalRequest);
} catch (refreshError) {
await clearTokens();
return Promise.reject(refreshError);
}
}
);
This interceptor performs the entire refresh process automatically.
Step 5: Prevent Multiple Refresh Requests
If several API calls receive a 401 Unauthorized response at the same time, you don't want each one to trigger its own refresh request. Instead, queue them behind a single refresh operation.
Add these variables near the top of client.ts:
let isRefreshing = false;
let pendingRequests: Array<
(token: string) => void
> = [];
Create helper functions:
function processQueue(token: string) {
pendingRequests.forEach(callback => callback(token));
pendingRequests = [];
}
Then update the response interceptor so that:
- The first failed request performs the refresh.
- Additional failed requests wait.
- Once new tokens are received, all queued requests are retried with the fresh access token.
This avoids race conditions and unnecessary network traffic.
Step 6: Handle Refresh Failure
If the refresh token has expired or been revoked:
- Remove all authentication data.
- Clear the user from
AuthContext. - Redirect the user to the login screen.
Since AppNavigator already reacts to isAuthenticated, logging the user out is simply a matter of clearing the authentication state. The navigation tree updates automatically without manual navigation calls.
Refresh Sequence
Access Token Expired
│
▼
Axios Response Interceptor
│
▼
Read Refresh Token
│
▼
POST /auth/refresh
│
▼
New Tokens
│
▼
Save Tokens
│
▼
Retry Original Request
│
▼
Return Response
This entire process happens transparently, so the user typically never notices that their access token has expired.
Why Use Interceptors?
Axios interceptors provide a centralized place to manage authentication concerns:
- Automatically attach access tokens to every request.
- Refresh expired access tokens without duplicating logic.
- Retry failed requests seamlessly.
- Avoid repetitive authentication code in screens and services.
- Keep the API layer clean and maintainable.
Production Considerations
For a production deployment, consider these additional enhancements:
- Store tokens in a secure storage solution such as
react-native-keychainor Expo SecureStore, instead of AsyncStorage. - Implement refresh token rotation on the backend.
- Hash refresh tokens before storing them in the database.
- Include a unique token identifier (
jti) in refresh tokens. - Track active sessions by device and provide a way for users to revoke them.
- Apply rate limiting to the refresh endpoint.
- Log refresh events for security auditing.
Current Project Structure
src/
├── api/
│ ├── authApi.ts
│ ├── client.ts
│ ├── config.ts
│ └── userApi.ts
├── constants/
│ └── storage.ts
├── context/
│ └── AuthContext.tsx
├── hooks/
├── navigation/
├── screens/
├── styles/
├── types/
├── utils/
│ └── tokenStorage.ts
└── validation/
At this point, your React Native application implements a complete authentication lifecycle with automatic JWT refresh, transparent request retries, and centralized token management. This architecture is well-suited for production environments and provides a smooth experience by keeping users signed in while maintaining short-lived access tokens.
Production Security Best Practices and Conclusion
Congratulations! You've successfully built a complete JWT authentication system using React Native and Node.js. Throughout this tutorial, you've implemented a modern authentication architecture that supports secure login, protected API access, session persistence, and automatic access token renewal.
Before deploying this application to production, it's important to review several best practices that can further strengthen the security, scalability, and maintainability of your authentication system.
Final Architecture
Your completed application now follows this authentication architecture:
React Native App
│
┌──────────────┼──────────────┐
│ │ │
▼ ▼ ▼
Login Screen Register Screen Home Screen
│ │
└──────────────┘
│
▼
AuthContext
│
▼
Axios Client
│
┌─────────────┴─────────────┐
│ │
▼ ▼
Request Interceptor Response Interceptor
│ │
▼ ▼
Access Token Refresh Token
│ │
└─────────────┬─────────────┘
▼
Express API
│
▼
JWT Authentication Middleware
│
▼
MongoDB
Each layer has a single responsibility, making the application easier to maintain and extend.
1. Use Secure Storage
Throughout this tutorial, authentication data is stored using AsyncStorage because it is simple and widely supported.
However, AsyncStorage is not encrypted.
For production applications, consider using:
react-native-keychain- Expo SecureStore (for Expo projects)
These solutions encrypt sensitive data and leverage the secure storage mechanisms provided by Android and iOS.
2. Keep Access Tokens Short-Lived
A common production configuration is:
| Token | Lifetime |
|---|---|
| Access Token | 10–15 minutes |
| Refresh Token | 7–30 days |
Short-lived access tokens reduce the impact of token theft while refresh tokens allow users to remain signed in without repeatedly entering their credentials.
3. Rotate Refresh Tokens
Instead of returning only a new access token, rotate the refresh token each time it is used.
Recommended flow:
Refresh Token
│
▼
Backend Verification
│
▼
Revoke Old Token
│
▼
Generate New Refresh Token
Generate New Access Token
│
▼
Return Both Tokens
This prevents a compromised refresh token from being reused after it has already been exchanged.
4. Hash Refresh Tokens
Avoid storing raw refresh tokens in your database.
Instead:
JWT Refresh Token
│
▼
SHA-256 Hash
│
▼
Store Hash
When a refresh request arrives:
- Hash the incoming token.
- Compare it with the stored hash.
- Reject the request if no match is found.
This mirrors the approach used for passwords and reduces the impact of a database leak.
5. Track User Sessions
A production-ready authentication system often allows users to manage their active sessions.
For each refresh token, consider storing additional metadata such as:
| Field | Purpose |
|---|---|
| Device Name | Identify the user's device |
| Platform | Android or iOS |
| IP Address | Audit and anomaly detection |
| Last Used | Track recent activity |
| Revoked | Invalidate individual sessions |
This enables features like "Sign out of this device" or "Sign out of all devices."
6. Validate Input
The React Native application already validates form input using Zod, but the backend should never rely solely on client-side validation.
Use a validation library such as:
- Zod
- express-validator
- Joi
Validate every incoming request before processing it.
7. Rate-Limit Authentication Endpoints
Authentication endpoints are common targets for brute-force attacks.
Apply rate limiting to routes such as:
/auth/login
/auth/register
/auth/refresh
This limits repeated requests from the same client within a specified time window.
8. Use HTTPS
Never transmit JWTs over unencrypted HTTP in production.
HTTPS ensures that:
- Tokens are encrypted in transit.
- User credentials cannot be intercepted by network attackers.
- Communication between the mobile app and the backend remains secure.
9. Protect Secrets
Never commit sensitive information such as:
- JWT signing secrets
- Database credentials
- API keys
Use environment variables and your deployment platform's secret management features instead.
10. Log Authentication Events
Logging important authentication events can help with monitoring and incident response.
Examples include:
- Successful logins
- Failed login attempts
- Token refresh operations
- Logout events
- Refresh token revocations
Avoid logging sensitive information such as passwords or JWT contents.
Authentication Lifecycle
The complete authentication lifecycle implemented in this tutorial is shown below:
User Login
│
▼
Verify Credentials
│
▼
Issue JWT Access Token
Issue Refresh Token
│
▼
Store Tokens
│
▼
Protected API Requests
│
▼
Access Token Expires
│
▼
Refresh Token Request
│
▼
Issue New Tokens
│
▼
Retry Original Request
│
▼
Continue Working
│
▼
Logout
│
▼
Revoke Refresh Token
What You Built
By following this tutorial, you've implemented:
- An Express 5 backend.
- MongoDB with Mongoose models.
- Secure password hashing with bcrypt.
- JWT access token authentication.
- Refresh token management.
- Protected API routes.
- React Native authentication using TypeScript.
- React Navigation 7 with authenticated and unauthenticated stacks.
- React Hook Form with Zod validation.
- Centralized authentication state using React Context.
- Axios request interceptors for attaching access tokens.
- Axios response interceptors for automatic token refresh.
- Persistent sessions using AsyncStorage.
- Automatic logout when refresh tokens expire.
This architecture provides a strong foundation for many types of mobile applications, including e-commerce platforms, social networks, enterprise tools, and SaaS products.
Possible Enhancements
From here, you can extend the authentication system with additional features, such as:
- Biometric authentication (Face ID or Fingerprint).
- Multi-factor authentication (MFA).
- Social login with Google, Apple, or Facebook.
- Password reset via email.
- Email verification.
- Role-based access control (RBAC).
- Refresh token rotation with hashed storage.
- Device management and active session lists.
- Offline authentication support.
- Push notification registration after login.
These enhancements build naturally on the architecture you've created.
Source Code
The complete source code for both the Node.js backend and the React Native application is available in the accompanying GitHub repository for this tutorial. Feel free to clone the project, experiment with the code, and adapt it to suit your own applications.
Conclusion
In this tutorial, you've built a complete JWT authentication system using React Native and Node.js, following modern development practices and a modular architecture. Starting from the backend, you created secure user registration and login endpoints, implemented JWT access and refresh tokens, protected API routes, and managed refresh token persistence with MongoDB.
On the React Native side, you built a TypeScript-based application featuring React Navigation, React Hook Form, Zod validation, centralized authentication state with React Context, and Axios interceptors for seamless token management. By implementing automatic access token renewal, users remain signed in without interruption while maintaining the security benefits of short-lived access tokens.
While the implementation presented here is suitable for many real-world applications, production deployments should also incorporate secure token storage, refresh token rotation, session management, rate limiting, and comprehensive logging. These additional measures help protect user accounts and improve the overall resilience of your authentication system.
With this foundation in place, you're well-equipped to integrate authentication into your own React Native projects and extend it with features such as biometric login, multi-factor authentication, or social sign-in as your application's requirements evolve.
We know that building beautifully designed Mobile and Web Apps from scratch can be frustrating and very time-consuming. Check Envato unlimited downloads and save development and design time.
That's just the basics. If you need more deep learning about Node.js and Express.js, you can take the following cheap course:
- NodeJS - The Complete Guide (MVC, REST APIs, GraphQL, Deno)
- Node.js, Express, MongoDB & More: The Complete Bootcamp
- Understanding Node.js: Core Concepts
- NodeJS Internals and Architecture
- Microservices with Node JS and React
- NodeJS Tutorial and Projects Course
- Complete NodeJS Developer (GraphQL, MongoDB, + more)
- Node JS: Advanced Concepts
- NodeJS Pro: Advanced Streams, Design Patterns, Performance
- Generative AI for NodeJs: OpenAI, LangChain - TypeScript
If you need more deep learning about React Native, you can take the following cheap course:
- React Native - The Practical Guide [2025]
- The Complete React Native + Hooks Course
- The Best React Native Course 2025 (From Beginner To Expert)
- React Native: Mobile App Development (CLI) [2025]
- React - The Complete Guide 2025 (incl. Next.js, Redux)
- React Native Development Simplified [2025]
- React Native by Projects: From Basics to Pro [2025]
- Full Stack Ecommerce Mobile App: React Native & Node.js 2025
- Learning Management System Mobile App using React Native
- React Native: Advanced Concepts
Happy coding! 🚀
