React Native + Node.js JWT Authentication with Refresh Tokens (2026 Guide)

by Didin J. on Jun 29, 2026 React Native + Node.js JWT Authentication with Refresh Tokens (2026 Guide)

How to build secure JWT authentication in React Native with a Node.js backend using access tokens, refresh tokens, Axios interceptors, and protected routes.

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:

  1. Create a free MongoDB Atlas account.
  2. Create a new cluster.
  3. Create a database user.
  4. Whitelist your IP address.
  5. 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 .env file 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
email 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:

  1. Mongoose checks whether the password has changed.
  2. If it did, the password is hashed automatically.
  3. 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:

  1. Verifies the email.
  2. Compares the password.
  3. Generates an access token.
  4. Generates a refresh token.
  5. Stores the refresh token in MongoDB.
  6. 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:

  1. Read the Authorization header.
  2. Verify it starts with Bearer.
  3. Extract the JWT.
  4. Validate the token using the access token secret.
  5. Store the decoded payload in req.user.
  6. 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-validator or zod.

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 Authorization header is present.
  • The header uses the format Bearer <token>.
  • The access token has not expired.

500 Internal Server Error

Verify:

  • MongoDB is running.
  • The .env configuration is correct.
  • The database connection is successful.

Invalid Signature

Ensure:

  • JWT_ACCESS_SECRET matches the secret used to generate the token.
  • JWT_REFRESH_SECRET matches 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.0 if 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:

  1. Calls the backend.
  2. Receives both JWTs.
  3. Saves them locally.
  4. 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.

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 from AuthContext.
  • 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:

  1. React Hook Form validates the input using the Zod schema.
  2. If validation succeeds, the login() method from AuthContext is called.
  3. AuthContext invokes the Node.js API and stores the returned access and refresh tokens in AsyncStorage.
  4. The authenticated user is saved in context.
  5. isAuthenticated becomes true.
  6. AppNavigator automatically 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:

  1. React Hook Form validates the input using the Zod schema.
  2. The form data is submitted to the /auth/register endpoint.
  3. If registration succeeds, AuthContext.register() automatically signs the user in.
  4. The access token, refresh token, and user profile are stored in AsyncStorage.
  5. The authentication state updates, causing AppNavigator to 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:

  1. Remove all authentication data.
  2. Clear the user from AuthContext.
  3. 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-keychain or 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:

  1. Hash the incoming token.
  2. Compare it with the stored hash.
  3. 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:

If you need more deep learning about React Native, you can take the following cheap course:

Happy coding! 🚀