Full-Stack Authentication: Implementing JWT and Refresh Tokens for Secure Sessions
Authentication is a fundamental aspect of almost every modern web application. While session-based authentication has been traditional, token-based authentication, particularly using JSON Web Tokens (JWTs), has gained significant popularity for its stateless nature and scalability. However, relying solely on short-lived JWTs can lead to frequent re-authentication, impacting user experience. This is where the concept of Refresh Tokens comes into play, providing a robust and secure way to manage long-lived user sessions.
This detailed guide will walk you through implementing full-stack authentication using JWTs and Refresh Tokens in a Node.js (Express) backend and a React frontend. We'll cover the entire flow, from user login to token refreshing and secure logout.
Understanding JWTs and Refresh Tokens
Let's clarify the roles of these two tokens:
- JSON Web Token (JWT) - The Access Token:
- A compact, URL-safe means of representing claims to be transferred between two parties.
- Contains user information (e.g., user ID, roles) in its payload.
- Is signed by the server, ensuring its integrity (cannot be tampered with).
- Is short-lived (e.g., 15 minutes to 1 hour) to minimize the window of opportunity for attackers if it's compromised.
- Sent with every request to access protected resources.
- Refresh Token:
- A long-lived token (e.g., days, weeks, or months).
- Used to obtain a new, short-lived Access Token when the current one expires.
- Stored securely on the client-side (e.g., in an HTTP-only cookie) and in the database on the server-side.
- Typically sent only to a dedicated refresh endpoint.
This dual-token approach provides a balance between security (short-lived access tokens) and user experience (long-lived sessions without frequent re-login).
Backend Setup: Node.js (Express) for Token Management
The backend will handle user registration, login, token generation, token validation, and token refreshing.
1. Install Dependencies
Navigate to your `server` directory and install the necessary packages:
npm install express mongoose bcryptjs jsonwebtoken cookie-parser dotenv
- `jsonwebtoken`: For creating and verifying JWTs.
- `cookie-parser`: Middleware to parse cookies from incoming requests.
- `bcryptjs`: For hashing passwords.
- `mongoose`: ODM for MongoDB.
- `dotenv`: For environment variables.
2. Environment Variables (`.env`)
Create a `.env` file in your `server` directory:
MONGO_URI=your_mongodb_connection_string
JWT_ACCESS_SECRET=your_access_token_secret
JWT_REFRESH_SECRET=your_refresh_token_secret
ACCESS_TOKEN_EXPIRY=15m # e.g., 15 minutes
REFRESH_TOKEN_EXPIRY=7d # e.g., 7 days
CLIENT_URL=http://localhost:3000 # Your React app's URL
3. User Model (`models/User.js`)
Create `server/models/User.js`. We'll add a field to store refresh tokens.
// server/models/User.js
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const UserSchema = new mongoose.Schema({
username: {
type: String,
required: true,
unique: true,
},
email: {
type: String,
required: true,
unique: true,
},
password: {
type: String,
required: true,
},
refreshTokens: [String], // Array to store multiple refresh tokens per user
});
// Hash password before saving
UserSchema.pre('save', async function (next) {
if (!this.isModified('password')) {
next();
}
const salt = await bcrypt.genSalt(10);
this.password = await bcrypt.hash(this.password, salt);
next();
});
// Method to compare passwords
UserSchema.methods.matchPassword = async function (enteredPassword) {
return await bcrypt.compare(enteredPassword, this.password);
};
module.exports = mongoose.model('User', UserSchema);
4. Authentication Middleware (`middleware/authMiddleware.js`)
Create `server/middleware/authMiddleware.js` to protect routes:
// server/middleware/authMiddleware.js
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const protect = async (req, res, next) => {
let token;
// Check for access token in Authorization header
if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) {
try {
token = req.headers.authorization.split(' ')[1];
// Verify token
const decoded = jwt.verify(token, process.env.JWT_ACCESS_SECRET);
// Attach user to the request object
req.user = await User.findById(decoded.id).select('-password -refreshTokens'); // Exclude sensitive fields
next();
} catch (error) {
console.error('Access token verification failed:', error);
res.status(401).json({ message: 'Not authorized, access token failed' });
}
}
if (!token) {
res.status(401).json({ message: 'Not authorized, no access token' });
}
};
module.exports = { protect };
5. Server Routes (`server.js`)
Modify your `server/server.js` to include authentication routes:
// server/server.js
const express = require('express');
const mongoose = require('mongoose');
const cors = require('cors');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const cookieParser = require('cookie-parser'); // Import cookie-parser
const User = require('./models/User');
const { protect } = require('./middleware/authMiddleware'); // Import auth middleware
require('dotenv').config();
const app = express();
const port = process.env.PORT || 5000;
// Middleware
app.use(cors({
origin: process.env.CLIENT_URL, // Allow requests from your React app
credentials: true, // Allow sending cookies
}));
app.use(express.json());
app.use(cookieParser()); // Use cookie-parser middleware
// MongoDB Connection
mongoose.connect(process.env.MONGO_URI)
.then(() => console.log('MongoDB connected'))
.catch(err => console.error(err));
// Helper function to generate tokens
const generateTokens = (id) => {
const accessToken = jwt.sign({ id }, process.env.JWT_ACCESS_SECRET, {
expiresIn: process.env.ACCESS_TOKEN_EXPIRY, // e.g., '15m'
});
const refreshToken = jwt.sign({ id }, process.env.JWT_REFRESH_SECRET, {
expiresIn: process.env.REFRESH_TOKEN_EXPIRY, // e.g., '7d'
});
return { accessToken, refreshToken };
};
// --- User Registration Route ---
app.post('/api/register', async (req, res) => {
const { username, email, password } = req.body;
try {
let user = await User.findOne({ email });
if (user) {
return res.status(400).json({ message: 'User already exists' });
}
user = new User({ username, email, password });
await user.save();
const { accessToken, refreshToken } = generateTokens(user._id);
// Store refresh token in database
user.refreshTokens.push(refreshToken);
await user.save();
// Set refresh token as HTTP-only cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // Accessible only by web server
secure: process.env.NODE_ENV === 'production', // Use secure in production
sameSite: 'Lax', // or 'Strict'
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
res.status(201).json({
message: 'User registered successfully',
accessToken,
user: { id: user._id, username: user.username, email: user.email },
});
} catch (error) {
console.error('Registration error:', error);
res.status(500).json({ message: 'Server error during registration.', error: error.message });
}
});
// --- User Login Route ---
app.post('/api/login', async (req, res) => {
const { email, password } = req.body;
try {
const user = await User.findOne({ email });
if (!user) {
return res.status(400).json({ message: 'Invalid credentials' });
}
const isMatch = await user.matchPassword(password);
if (!isMatch) {
return res.status(400).json({ message: 'Invalid credentials' });
}
const { accessToken, refreshToken } = generateTokens(user._id);
// Add new refresh token to user's refreshTokens array
user.refreshTokens.push(refreshToken);
await user.save();
// Set refresh token as HTTP-only cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'Lax',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
res.status(200).json({
message: 'Logged in successfully',
accessToken,
user: { id: user._id, username: user.username, email: user.email },
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ message: 'Server error during login.', error: error.message });
}
});
// --- Refresh Token Route ---
app.post('/api/refresh-token', async (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({ message: 'No refresh token provided' });
}
try {
// Verify refresh token
const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
// Find user by ID and check if refresh token exists in DB
const user = await User.findById(decoded.id);
if (!user || !user.refreshTokens.includes(refreshToken)) {
// If token is not found or not associated with user, clear cookie and deny
res.clearCookie('refreshToken', { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'Lax' });
return res.status(403).json({ message: 'Invalid refresh token. Please log in again.' });
}
// Generate new access token
const newAccessToken = jwt.sign({ id: user._id }, process.env.JWT_ACCESS_SECRET, {
expiresIn: process.env.ACCESS_TOKEN_EXPIRY,
});
res.status(200).json({ accessToken: newAccessToken });
} catch (error) {
console.error('Refresh token error:', error);
res.status(403).json({ message: 'Invalid or expired refresh token. Please log in again.' });
}
});
// --- User Logout Route ---
app.post('/api/logout', async (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(204).json({ message: 'No refresh token to clear' }); // No content
}
try {
// Find user by refresh token and remove it from DB
const user = await User.findOne({ refreshTokens: refreshToken });
if (user) {
user.refreshTokens = user.refreshTokens.filter(token => token !== refreshToken);
await user.save();
}
// Clear the HTTP-only cookie
res.clearCookie('refreshToken', { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'Lax' });
res.status(200).json({ message: 'Logged out successfully' });
} catch (error) {
console.error('Logout error:', error);
res.status(500).json({ message: 'Server error during logout.', error: error.message });
}
});
// --- Protected Route Example ---
app.get('/api/protected', protect, (req, res) => {
res.status(200).json({
message: `Welcome, ${req.user.username}! This is a protected resource.`,
user: req.user,
});
});
// Start the server
app.listen(port, () => {
console.log(`Server running on port ${port}`);
});
```
Explanation of Backend Code:
- `generateTokens(id)`: Helper to create both access and refresh tokens.
- `cookie-parser` & `res.cookie()`: Used to set the refresh token as an HTTP-only cookie. This is a common and secure way to store refresh tokens on the client-side, as it prevents JavaScript from accessing them, mitigating XSS attacks.
- `/api/register` & `/api/login`:
- Generate both tokens upon successful registration/login.
- Store the refresh token in the user's document in MongoDB (for revocation/tracking).
- Send the access token in the JSON response to the frontend.
- Set the refresh token as an HTTP-only cookie.
- `/api/refresh-token`:
- Retrieves the refresh token from the HTTP-only cookie.
- Verifies the refresh token.
- Checks if the token is valid and exists in the user's `refreshTokens` array in the database.
- If valid, issues a *new* access token.
- `/api/logout`:
- Retrieves the refresh token from the cookie.
- Removes that specific refresh token from the user's document in the database.
- Clears the HTTP-only cookie on the client-side.
- `protect` middleware:
- Extracts the access token from the `Authorization: Bearer` header.
- Verifies the access token using `jwt.verify()`.
- If valid, attaches the `user` object to the `req` object, allowing subsequent route handlers to access user data.
Frontend Setup: React for Token Management
Your React frontend will handle storing the access token (e.g., in local storage or a Redux store), attaching it to requests, and implementing the refresh token logic.
1. Install Dependencies (if not already)
Navigate to your `client` directory:
npm install react-router-dom axios
- `react-router-dom`: For routing.
- `axios`: A popular promise-based HTTP client, often preferred over `fetch` for its features, including interceptors.
2. Axios Instance with Interceptors (`axiosInstance.js`)
This is crucial for automatically attaching access tokens and handling token refreshes. Create `client/src/utils/axiosInstance.js`:
// client/src/utils/axiosInstance.js
import axios from 'axios';
const axiosInstance = axios.create({
baseURL: 'http://localhost:5000/api', // Your backend API base URL
withCredentials: true, // Crucial for sending HTTP-only cookies (refresh token)
});
// Request Interceptor: Attach access token to outgoing requests
axiosInstance.interceptors.request.use(
(config) => {
const accessToken = localStorage.getItem('accessToken'); // Get access token from local storage
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response Interceptor: Handle expired access tokens and refresh them
axiosInstance.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// If the error is 401 (Unauthorized) and it's not the refresh token endpoint itself
// and we haven't already tried to refresh for this request
if (error.response.status === 401 && !originalRequest._retry) {
originalRequest._retry = true; // Mark as retried to prevent infinite loops
try {
// Call the refresh token endpoint
const response = await axiosInstance.post('/refresh-token'); // No need for full URL, baseURL is set
const newAccessToken = response.data.accessToken;
// Store the new access token
localStorage.setItem('accessToken', newAccessToken);
// Update the original request's header with the new token
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
// Retry the original request with the new access token
return axiosInstance(originalRequest);
} catch (refreshError) {
// If refresh fails (e.g., refresh token expired or invalid),
// clear tokens and redirect to login
localStorage.removeItem('accessToken');
// You might want to dispatch a logout action here if using Redux
window.location.href = '/login'; // Redirect to login page
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
export default axiosInstance;
Explanation of `axiosInstance.js`:
- `baseURL`: Sets the base URL for all requests, making API calls cleaner.
- `withCredentials: true`: Essential for Axios to send and receive HTTP-only cookies (our refresh token).
- Request Interceptor: Before any request is sent, it checks for an `accessToken` in local storage and adds it to the `Authorization` header.
- Response Interceptor:
- If a response returns a `401 Unauthorized` status (meaning the access token is expired or invalid), it attempts to refresh the token.
- It makes a request to `/api/refresh-token` (which automatically sends the HTTP-only refresh cookie).
- If successful, it updates the `accessToken` in local storage and retries the original failed request with the new token.
- If the refresh fails, it clears the access token and redirects the user to the login page.
3. Login Component (`Login.js`)
Create `client/src/components/Login.js`:
// client/src/components/Login.js
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import axiosInstance from '../utils/axiosInstance'; // Use the configured Axios instance
function Login() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [message, setMessage] = useState('');
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setMessage('');
try {
const response = await axiosInstance.post('/login', { email, password }); // Use axiosInstance
localStorage.setItem('accessToken', response.data.accessToken); // Store access token
// You might also store user info: localStorage.setItem('user', JSON.stringify(response.data.user));
setMessage(response.data.message);
navigate('/dashboard'); // Redirect to a protected dashboard page
} catch (error) {
console.error('Login error:', error.response?.data || error.message);
setMessage(error.response?.data?.message || 'Login failed.');
} finally {
setLoading(false);
}
};
return (
<div style={{
backgroundColor: '#fff',
padding: '30px',
borderRadius: '10px',
boxShadow: '0 4px 10px rgba(0,0,0,0.1)',
width: '100%',
maxWidth: '500px',
margin: '20px auto',
boxSizing: 'border-box',
textAlign: 'left'
}}>
<h2 style={{
fontFamily: 'Merriweather, serif',
color: '#222',
fontSize: '1.8em',
marginBottom: '20px',
borderBottom: '2px solid #eee',
paddingBottom: '10px',
boxSizing: 'border-box'
}}>Login</h2>
<form onSubmit={handleSubmit} style={{display: 'flex', flexDirection: 'column', gap: '15px', boxSizing: 'border-box'}}>
<div style={{boxSizing: 'border-box'{{>
<label htmlFor="email" style={{display: 'block', marginBottom: '5px', fontWeight: 'bold', color: '#555', boxSizing: 'border-box'}}>Email:</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
style={{width: '100%', padding: '10px', border: '1px solid #ddd', borderRadius: '5px', boxSizing: 'border-box'}}
/>
</div>
<div style={{boxSizing: 'border-box'{{>
<label htmlFor="password" style={{display: 'block', marginBottom: '5px', fontWeight: 'bold', color: '#555', boxSizing: 'border-box'}}>Password:</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
style={{width: '100%', padding: '10px', border: '1px solid #ddd', borderRadius: '5px', boxSizing: 'border-box'}}
/>
</div>
<button type="submit" disabled={loading} style={{
backgroundColor: loading ? '#ccc' : '#c00',
color: '#fff',
padding: '12px 25px',
border: 'none',
borderRadius: '8px',
cursor: loading ? 'not-allowed' : 'pointer',
fontSize: '1em',
fontWeight: 'bold',
transition: 'background-color 0.3s ease',
boxShadow: '0 2px 5px rgba(0,0,0,0.2)',
boxSizing: 'border-box'
}}>
{loading ? 'Logging In...' : 'Login'}
</button>
</form>
{message && (
<p style={{
marginTop: '20px',
color: message.includes('successfully') ? 'green' : 'red',
fontSize: '1em',
boxSizing: 'border-box'
}}>
{message}
</p>
)}
</div>
);
}
export default Login;
```
4. Protected Dashboard Component (`Dashboard.js`)
Create `client/src/components/Dashboard.js` to test the protected route:
// client/src/components/Dashboard.js
import React, { useEffect, useState } => {
import axiosInstance from '../utils/axiosInstance';
import { useNavigate } from 'react-router-dom';
function Dashboard() {
const [data, setData] = useState(null);
const [message, setMessage] = useState('Loading protected data...');
const navigate = useNavigate();
useEffect(() => {
const fetchProtectedData = async () => {
try {
const response = await axiosInstance.get('/protected'); // Use axiosInstance
setData(response.data.user);
setMessage(response.data.message);
} catch (error) {
console.error('Protected data error:', error.response?.data || error.message);
setMessage(error.response?.data?.message || 'Failed to fetch protected data.');
// If 401/403, the interceptor should handle redirecting to login
}
};
fetchProtectedData();
}, []);
const handleLogout = async () => {
try {
await axiosInstance.post('/logout'); // Call logout endpoint
localStorage.removeItem('accessToken'); // Clear access token from local storage
setMessage('Logged out successfully.');
navigate('/login'); // Redirect to login page
} catch (error) {
console.error('Logout error:', error.response?.data || error.message);
setMessage(error.response?.data?.message || 'Logout failed.');
}
};
return (
<div style={{
backgroundColor: '#fff',
padding: '30px',
borderRadius: '10px',
boxShadow: '0 4px 10px rgba(0,0,0,0.1)',
width: '100%',
maxWidth: '600px',
margin: '20px auto',
boxSizing: 'border-box',
textAlign: 'left'
}}>
<h2 style={{
fontFamily: 'Merriweather, serif',
color: '#222',
fontSize: '1.8em',
marginBottom: '20px',
borderBottom: '2px solid #eee',
paddingBottom: '10px',
boxSizing: 'border-box'
}}>Dashboard</h2>
<p style={{color: '#555', marginBottom: '15px', boxSizing: 'border-box'}}>{message}</p>
{data && (
<div style={{borderTop: '1px solid #eee', paddingTop: '15px', boxSizing: 'border-box'}}>
<h3 style={{fontFamily: 'Merriweather, serif', fontSize: '1.4em', color: '#007bff', marginBottom: '10px', boxSizing: 'border-box'}}>User Info:</h3>
<p style={{marginBottom: '5px', boxSizing: 'border-box'}}><strong style={{boxSizing: 'border-box'}}>Username:</strong> {data.username}</p>
<p style={{boxSizing: 'border-box'}}><strong style={{boxSizing: 'border-box'}}>Email:</strong> {data.email}</p>
</div>
)}
<button onClick={handleLogout} style={{
backgroundColor: '#dc3545',
color: '#fff',
padding: '12px 25px',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '1em',
fontWeight: 'bold',
transition: 'background-color 0.3s ease',
boxShadow: '0 2px 5px rgba(0,0,0,0.2)',
marginTop: '30px',
boxSizing: 'border-box'
}}>Logout</button>
</div>
);
}
export default Dashboard;
5. Update `App.js` for Routing
Modify `client/src/App.js` to set up basic routing for your components:
// client/src/App.js
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
import Register from './components/Register';
import Login from './components/Login';
import Dashboard from './components/Dashboard';
function App() {
return (
<Router>
<div style={{
fontFamily: 'Open Sans, sans-serif',
textAlign: 'center',
padding: '40px',
backgroundColor: '#f0f2f5',
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
boxSizing: 'border-box'
}}>
<h1 style={{
fontFamily: 'Merriweather, serif',
color: '#c00',
fontSize: '3em',
marginBottom: '20px',
boxSizing: 'border-box'
}}>JWT Auth Demo</h1>
<nav style={{marginBottom: '30px', boxSizing: 'border-box'}}>
<ul style={{listStyle: 'none', padding: '0', margin: '0', display: 'flex', gap: '20px', boxSizing: 'border-box'}}>
<li style={{boxSizing: 'border-box'}}><Link to="/register" style={{
color: '#007bff', textDecoration: 'none', fontSize: '1.1em', fontWeight: 'bold', boxSizing: 'border-box'
}}>Register</Link></li>
<li style={{boxSizing: 'border-box'}}><Link to="/login" style={{
color: '#007bff', textDecoration: 'none', fontSize: '1.1em', fontWeight: 'bold', boxSizing: 'border-box'
}}>Login</Link></li>
<li style={{boxSizing: 'border-box'}}><Link to="/dashboard" style={{
color: '#007bff', textDecoration: 'none', fontSize: '1.1em', fontWeight: 'bold', boxSizing: 'border-box'
}}>Dashboard</Link></li>
</ul>
</nav>
<Routes>
<Route path="/register" element={<Register />} />
<Route path="/login" element={<Login />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/" element={<Login />} /> {/* Default route */}
</Routes>
</div>
</Router>
);
}
export default App;
Authentication Flow: JWT and Refresh Tokens
Here's a summary of the authentication flow with JWTs and Refresh Tokens:
- User Logs In (React): User submits credentials via the `Login` component.
- Backend Issues Tokens (Node.js):
- Upon successful login, the backend generates a short-lived Access Token and a long-lived Refresh Token.
- The Access Token is sent in the JSON response.
- The Refresh Token is set as an HTTP-only cookie in the response.
- The Refresh Token is also stored in the database, associated with the user.
- Frontend Stores Access Token (React): The React app stores the Access Token (e.g., in `localStorage`). The Refresh Token is automatically handled by the browser as an HTTP-only cookie.
- Accessing Protected Resources (React to Node.js): For every subsequent request to a protected route, the Axios request interceptor attaches the Access Token from `localStorage` to the `Authorization: Bearer` header.
- Access Token Expiration & Refresh (Axios Interceptor & Node.js):
- If the Access Token expires, the backend responds with `401 Unauthorized`.
- The Axios response interceptor catches this `401` error.
- It then automatically sends a request to the `/api/refresh-token` endpoint. The HTTP-only refresh token cookie is automatically sent with this request.
- The backend validates the refresh token against the database. If valid, it issues a *new* Access Token.
- The new Access Token is returned to the frontend, stored in `localStorage`, and the original failed request is retried.
- Logout (React to Node.js): When the user logs out, the frontend sends a request to `/api/logout`. The backend removes the refresh token from the database and clears the HTTP-only cookie, effectively ending the session.
Conclusion
Implementing full-stack authentication with JWTs and Refresh Tokens provides a robust and scalable solution for managing user sessions in modern web applications. This dual-token strategy balances security (short-lived access tokens) with user experience (long-lived sessions). By carefully handling token generation, storage, validation, and refreshing on both the Node.js backend and React frontend, you can build a secure and seamless authentication system that protects your application's resources while providing a smooth user journey.