Please visit this post first, where I have made this project from scratch and wrote CRUD APIs.
Source code
- Backend and specific only to this tutorial: https://github.com/rd003/ng-node-fullstack/pull/new/jwt-refresh
- Complete project with angular : https://github.com/rd003/ng-node-fullstack
JSON web token (JWT) and why it is needed
REST Apis are session less. You can not maintain the authentication session for the whole application. For that purpose, JSON web token (JWT)
comes in play. How it works
- User hit the
login or authenticate
endpoint with valid credentials. - In response, if user is authenticated, user get’s a
access-token (JWT)
, which contains the user’s info like username, role. - You need to pass JWT in authorization header with every protected endpoint.
- In this a server knows a valid and authenticated use is accessing the resource.
- JWT has a expiry date, it does not live forever.
- Note that, JWT is not saved in database
What is the refresh token and why we need it?
You are thinking that, if I can do authentication with JWT, then why do I bother to create a refresh token. Let me break it down
- For security reasons, it is reccomended to set a short expiry time to JWT, preferably 15 minutes. They live for a short period.
- Which means, you have to login in every 15 minutes. Well, it seems a pain.
- Now refresh tokens comes in a play.
Now authentication flow will be different
- User hits the
login or authenticate
endpoint by passing valid credentials. - If logged in successfull, two tokens are generated:
refresh-token
andaccess-token (JWT)
.refresh-token
get saved in the database, along with it’s validity and validity ofrefresh-token
is long (30 days or more). Let’s say we have set refresh-token’s expiry to 30 days. - In response, user gets two tokens:
access-token
andrefresh-token
. - When
access-token
expires, user have to callrefreshToken
api endpoint and passaccess-token + refresh+ token
and in response, the user gets newly generated access and refresh token. - Which means you don’t need to login for next 30 days.
- After 30 days when
refresh-token
is expired, a user have tologin
again to get newrefresh-token
.
Soure code : https://github.com/rd003/ng-node-fullstack/tree/jwt/backend
user model
const { allow } = require('joi');
const { DataTypes } = require('sequelize');
function userModel(sequelize) {
const attributes = {
Id: {
type: DataTypes.INTEGER,
allowNull: false,
autoIncrement: true,
primaryKey: true
},
Email: {
type: DataTypes.STRING(100),
allowNull: false
},
PasswordHash: {
type: DataTypes.STRING(200),
allowNull: false
},
Role: {
type: DataTypes.STRING(20),
allowNull: false
},
RefreshToken: {
type: DataTypes.STRING(400),
allowNull: true
},
RefreshTokenExpiry:{
type: DataTypes.DATE,
allow:true
}
};
const options = {
freezeTableName: true,
timestamps: false,
indexes: [
{
unique: true,
fields: ['Email'],
name: 'UIX_Email'
}
]
};
return sequelize.define("Users", attributes, options);
}
module.exports = userModel;
config/database.js
db.Users = userModel(sequelize);
Validate User (/middlewares/user.validator.js)
const Joi = require('joi');
const userSchema = Joi.object({
email: Joi.string()
.email({ minDomainSegments: 2, tlds: { allow: ['com', 'net', 'org', 'edu', 'gov', 'mil', 'int', 'co', 'io', 'me', 'info', 'biz'] } })
.min(5)
.max(100)
.required()
.messages({
'string.email': 'Please provide a valid email address',
'string.min': 'Email must be at least 5 characters long',
'string.max': 'Email must not exceed 100 characters',
'any.required': 'Email is required'
}),
password: Joi.string()
.min(8)
.max(70)
.pattern(new RegExp('^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]'))
.required()
.messages({
'string.min': 'Password must be at least 8 characters long',
'string.max': 'Password must not exceed 70 characters',
'string.pattern.base': 'Password must contain at least 1 uppercase letter, 1 lowercase letter, 1 number, and 1 special character (@$!%*?&)',
'any.required': 'Password is required'
})
});
const loginSchema = Joi.object({
username: Joi.string()
.min(1)
.max(100)
.required(),
password: Joi.string()
.min(1)
.max(70)
.required()
});
const validateUser = (req, res, next) => {
const { error } = userSchema.validate(req.body);
if (error) {
res.status(400)
.json({
statusCode: 400,
message: "Validation failed",
errors: error.details.map(detail => detail.message)
});
}
next();
}
const validateLogin = (req, res, next) => {
const { error } = loginSchema.validate(req.body);
if (error) {
res.status(400)
.json({
statusCode: 400,
message: "Validation failed",
errors: error.details.map(detail => detail.message)
});
}
next();
}
module.exports = {
validateUser,
validateLogin
}
Required libraries
First, we need to install these packages.
npm i bcrypt jsonwebtoken cookie-parser
bcrypt
for hashing the passwordjsonwebtoken
for creating and verifying jwtcookie-parser
for retrieving and parsing the cookie
Controllers
//controllers/user.controller.js
const { Users } = require('../config/database');
const bcrypt = require('bcrypt');
const { convertToMilliseconds } = require('../utils/time.util');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key'; // Use a strong secret in production
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '15m';
const REFRESH_EXPIRY = process.env.REFRESH_TOKEN_EXPIRES_IN || '7d';
// functions
// at the end of the file
module.exports = {
signup,
login,
logout,
refreshAccessToken,
getUserInfo
};
Now we will define these functions.
signup
const signup = async (req, res) => {
try {
// whether user exists
const user = await Users.findOne({
where: { email: req.body.email }
});
if (user) {
return res.status(409).json({
statusCode: 409,
message: "User already exists"
});
}
// create new user
const saltRounds = 10;
const passwordHash = await bcrypt.hash(req.body.password, saltRounds);
await Users.create({
Email: req.body.email,
PasswordHash: passwordHash,
Role: "user"
});
res.status(201).send({
statusCode: 201,
message: "User is created"
});
}
catch (error) {
console.log(`❌=====>${error}`);
res.status(500).json({
statusCode: 500,
message: "Internal server error"
});
}
}
login
First we need to add these values in .env
file.
JWT_SECRET=your-very-secure-secret-key-here
JWT_EXPIRES_IN=15m
REFRESH_TOKEN_EXPIRES_IN=30d
cokie-parser
cookie-parser
library is need to get the cookies. Make sure you have installed it already. Now we need to to set cookie-parser
middleware
//app.js
app.use(cookieParser())
time converter utility
We are using the time defined in .env
file (JWT_EXPIRES_IN
) as maxAge
of cookie. But cookies, maxAge
value needs to be set in milliseconds and our JWT_EXPIRES_IN
values is 24h
. So we need a converter.
/src/utils/time.util.js
const convertToMilliseconds = (timeString) => {
if (!timeString) return 24 * 60 * 60 * 1000; // Default to 24 hours
const timeValue = parseInt(timeString);
const timeUnit = timeString.slice(-1).toLowerCase();
switch (timeUnit) {
case 's': // seconds
return timeValue * 1000;
case 'm': // minutes
return timeValue * 60 * 1000;
case 'h': // hours
return timeValue * 60 * 60 * 1000;
case 'd': // days
return timeValue * 24 * 60 * 60 * 1000;
default:
// If no unit specified, assume seconds
return timeValue * 1000;
}
};
module.exports = {
convertToMilliseconds
};
generate access and refresh tokens in pair
const generateAccessAndRefreshTokens = (payload) => {
const accessToken = jwt.sign(payload, JWT_SECRET, {
expiresIn: JWT_EXPIRES_IN
});
const refreshToken = jwt.sign({
username: payload.username
}, JWT_SECRET, {
expiresIn: REFRESH_EXPIRY
});
return { accessToken, refreshToken };
}
I could generate refresh token with other ways, but in this way we can get username
from refreshToken in refresh
endpoint. We have no other way to retrieve username
in the refresh endpoint, if we are working with http cookies only. We could utilize the accessToken
and extract username
from its payload, but accessToken cookie would have been exprise at the time of refresh
endpoint, so it can not be used in refresh
endpoint. However, you can access the accessToken
if it would not be passed through http only cookie
, means it has passed throught the body, then we can easily utilize the accessToken.
login function
const login = async (req, res) => {
try {
const user = await Users.findOne({ where: { email: req.body.username } });
if (user === null) {
return res.status(401).json({
statusCode: 401,
message: 'Invalid username or password'
});
}
const isMatch = await bcrypt.compare(req.body.password, user.PasswordHash);
if (!isMatch) {
return res.status(401).json({
statusCode: 401,
message: 'Invalid username or password'
});
}
// generate access and refresh tokens
const payload = {
username: user.Email,
role: user.Role
};
const { accessToken, refreshToken } = generateAccessAndRefreshTokens(payload);
// save refresh token to db
user.RefreshToken = refreshToken;
user.RefreshTokenExpiry = new Date(Date.now() + convertToMilliseconds(REFRESH_EXPIRY));
user.save();
// set token in cookie
const jwtCookieOptions = {
httpOnly: true,
secure: true,
maxAge: convertToMilliseconds(JWT_EXPIRES_IN),
sameSite: 'strict'
}
const refreshTokenCookieOptions = {
httpOnly: true,
secure: true,
maxAge: convertToMilliseconds(REFRESH_EXPIRY),
path: '/api/auth/refresh', // Only sent to refresh endpoint
sameSite: 'strict'
}
// since these api can be access by mobile app too, where cookies don't work, so we need to send tokens as a response too.
res
.status(200)
.cookie("accessToken", accessToken, jwtCookieOptions)
.cookie("refreshToken", refreshToken, refreshTokenCookieOptions)
.json({
"accessToken": accessToken,
"refreshToken": refreshToken
})
}
catch (error) {
console.log(`❌=====>${error}`);
return res.status(500).json({
statusCode: 500,
message: "Internal server error"
});
}
}
Let’s see what is the meaning of attributes in the cookie options.
httpOnly
:true
means, it can only be accessed and used by the server. A javascript client can not read its value, which protects it from XSS attacks.secure
: Iftrue
then can only be accessed overhttps
maxAge
: Expiration time of cookie. After this time cookie is automatically deleted from the browser.sameSite
:strict
says it can not be sent with cross site requests.
logout
const logout = async (req, res) => {
try {
// removing refresh token from db
// First we need to get username/email, which can be obtained from req.user, which has been set in authentication middleware. Since it is an authenticated route, so we can access req.user
const username = req.user.username;
const user = await Users.findOne({
where: {
Email: username
}
});
user.RefreshToken = null;
user.RefreshTokenExpiry = null;
user.save();
res.status(200)
.clearCookie("accessToken")
.clearCookie("refreshToken", { path: '/api/auth/refresh' })
.json({
"statusCode": 200,
"message": "You are successfully logged out"
});
} catch (error) {
console.log(`❌=====>${error}`);
res.status(500).json({
statusCode: 500,
message: "Internal server error"
});
}
}
refresh token
const refreshAccessToken = async (req, res) => {
try {
const refreshTokenInReq = req.cookies?.refreshToken || req.body?.refreshToken;
if (!refreshTokenInReq) {
//console.log("===> Step 4: No refresh token, returning 401");
return res.status(401).json({
statusCode: 401,
message: 'refreshToken is not provided'
});
}
// return immediately if token is invalid
let decodedRefreshToken;
try {
decodedRefreshToken = jwt.verify(refreshTokenInReq, JWT_SECRET);
} catch (error) {
console.log(`=====> ${error}`);
return res.status(401).json({
statusCode: 401,
message: 'Invalid refresh token'
});
}
console.log(`decoded refresh token: ${JSON.stringify(decodedRefreshToken)}`);
const user = await Users.findOne({
where: {
Email: decodedRefreshToken.username
}
});
// Check if refresh token is valid, not compromised and not expired
if (!user || user.RefreshToken !== refreshTokenInReq || user.RefreshTokenExpiry < Date.now()) {
return res.status(400).json({
statusCode: 400,
message: 'Refresh token is invalid or expired.'
});
}
// generate new access and refresh token
const payload = {
username: user.Email,
role: user.Role
};
const { accessToken, refreshToken } = generateAccessAndRefreshTokens(payload);
// Rotating the refresh token. In this way attacker have less time window to attack
user.RefreshToken = refreshToken;
user.save();
// set tokens in cookie and also return them in response
const jwtCookieOptions = {
httpOnly: true,
secure: true,
maxAge: convertToMilliseconds(JWT_EXPIRES_IN),
sameSite: 'strict'
}
// refresh token options
// If we fetch maxAge from .env file on every refresh, our refreshToken cookie will never be expired.
// We need to calculate the maxAge on the basis of refreshTokenExpiry in db
const expiryDate = new Date(user.RefreshTokenExpiry);
const currentDate = new Date();
const remainingTimeMs = expiryDate - currentDate;
const refreshTokenCookieOptions = {
httpOnly: true,
secure: true,
maxAge: remainingTimeMs > 0 ? remainingTimeMs : 0,
path: '/api/auth/refresh', // Only sent to refresh endpoint
sameSite: 'strict'
}
console.log("====> new refresh token has generated.");
res
.status(200)
.cookie("accessToken", accessToken, jwtCookieOptions)
.cookie("refreshToken", refreshToken, refreshTokenCookieOptions)
.json({
"accessToken": accessToken,
"refreshToken": refreshToken
});
} catch (error) {
console.log(`❌user.controller/refresh => catch=====>${error}`);
return res.status(500).json({
statusCode: 500,
message: "Internal server error"
});
}
}
Get user info
const getUserInfo = async (req, res) => {
try {
const username = req.user.username;
const user = await Users.findOne({
attributes: [['Email', 'username'], ['Role', 'role']],
where: {
Email: username
}
});
// It is unlikely to happen
if (!user) {
return res.status(404).json({
statusCode: 404,
message: "User not found"
});
}
res.status(200).json(user);
}
catch (error) {
console.log(`❌=====>${error}`);
res.status(500).json({
statusCode: 500,
message: "Internal server error"
});
}
}
Authentication middlewares
How do we know, a user which is accessing the endpoint is authenticated user or not. It is called authentication
. What if you don’t want to give every access to a user, eg. only admin
can delete
a resource, it is called authorization
In short, you need to protect your routes.
Layman terminology: Let’s say you want to enter in a
science museum
. You need auser-pass
for that. When you give yourpass
to a security personalle, you are allowed to enter to themuseum
. You enters the museum, there are certain places, where anormal user
like you can not enter, even you have auser-pass
. But thoseuser-pass
are not allowed there. You need aspecial-pass
to enter there. It is calledauthorization
.Authentication: Who you are
Authorization: What you can access
For that purpose, we need to create authentication middleware, which will be injected into a route, and responsible for checking authentication.
// /middleware/authentication.middleware.js
const jwt = require('jsonwebtoken');
const JWT_SECRET = process.env.JWT_SECRET;
// here we will define our middlewares
// authentication middleware
// required role middleware
// end of the file
module.exports = {
authenticateToken,
requiredRoles
}
authentication middleware
It is responsible, for checking the authentication.
const authenticateToken = (req, res, next) => {
const token = req.cookies?.accessToken || req.header("Authorization")?.replace("Bearer ", "");
console.log(`======> jwt: ${token}`);
if (!token) {
return res.status(401).json({
statusCode: 401,
message: 'Access token required'
});
}
jwt.verify(token, JWT_SECRET, (err, user) => {
if (err) {
return res.status(403).json({
statusCode: 403,
message: 'Invalid or expired token'
});
}
console.log(`=====> JWT verified => req.user : ${JSON.stringify(user)} `);
req.user = user;
})
next();
}
middleware for checking allowed roles
// Middleware to check specific roles
const requiredRoles = (allowedRoles) => {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({
statusCode: 401,
message: 'Authentication required'
});
}
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({
statusCode: 403,
message: 'Insufficient permissions'
});
}
next();
}
}
User routes
// /routes/user.route.js
const express = require("express");
const { signup, login, logout, refreshAccessToken, getUserInfo } = require("../controllers/user.controller");
const { validateUser, validateLogin } = require("../middleware/user.validator");
const { authenticateToken } = require("../middleware/authentication.middleware");
router = express.Router();
router.post('/signup', validateUser, signup);
router.post('/login', validateLogin, login);
router.get('/me', authenticateToken, getUserInfo)
router.post('/logout', authenticateToken, logout)
router.post('/refresh', refreshAccessToken)
module.exports = router;
Note: authenticateToken
is a middleware that checks whether a client is authenticated or not. In short no one can access the logout
endpoint without a valid accessToken
.
route/index.js
const userRoutes = require('./user.routes');
router.use('/api/auth', userRoutes);
Make sure your cors has set credentials to true
// app.js
app.use(cors({
origin: 'http://localhost:4200',
credentials: true // it is essential to set cookies
}));
Protect the person endpoints
// /routes/personRoutes.js
const express = require('express');
const router = express.Router();
const { validatePerson, validatePersonUpdate } = require('../middleware/validation');
const { getAllPeople, getPersonById, createPerson, updatePerson, deletePerson } = require('../controllers/personController');
const { authenticateToken, requiredRoles } = require('../middleware/authentication.middleware');
router.get('/', authenticateToken, getAllPeople);
router.get('/:id', authenticateToken, getPersonById);
router.post('/', authenticateToken, validatePerson, createPerson);
router.put('/:id', authenticateToken, validatePersonUpdate, updatePerson);
router.delete('/:id', authenticateToken, requiredRoles(['admin']), deletePerson);
module.exports = router;
Note: authenticateToken
is a middlware, which checks wheter a user is authenticated or not. requiredRoles
restricts endpoint access rights to certain roles
.
Testing auth apis
user.http:
@base_address = http://localhost:3000/api/auth
POST {{base_address}}/signup
Content-Type : application/json
{
"email": "john@example.com",
"password": "John@123"
}
###
POST {{base_address}}/login
Content-Type : application/json
{
"username": "john@example.com",
"password": "John@123"
}
###
GET {{base_address}}/me
Authorization: Bearer {{jwt}}
###
POST {{base_address}}/logout
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImpvaG5AZXhhbXBsZS5jb20iLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3NDk3MDc4MTgsImV4cCI6MTc0OTcwNzg3OH0.e7RFzaMuGSBg1dPt2rLR7dRo29N1CgtyvahavYJOevA
### Refresh token
POST {{base_address}}/refresh
Content-Type: application/json
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImpvaG5AZXhhbXBsZS5jb20iLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3NDk3MDc2NjQsImV4cCI6MTc0OTcwNzcyNH0.2D7bn3y4gm3_tGCrBrW4yharyMBfa91Tz6W4SZiVGYk",
"refreshToken": "1067ad48435b25efa16314ab110c4c6830d7b4edefdbff23674a522d0ff7c837169c5b356f1d1ebf2ba0471ae97bd499e246ebbf38a6a2cca4066dad409c8183"
}
Front end
Angular
In angular you have to pass {withCredential:true}
to set and send the cookies.
login(loginData: LoginModel) {
return this.http.post<TokenModel>(
this.apiUrl + "/login",
loginData,
{
withCredentials: true
}
);
}
Note that, you have do the same, in every http request which intent to send or recieve the cookie. It is better to create an interceptor for it.
// http.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
export const httpInterceptor: HttpInterceptorFn = (req, next) => {
const newReq = req.clone({
withCredentials: true
});
return next(newReq);
};
// app.config.ts
provideHttpClient(
withInterceptors([httpInterceptor])
)
FETCH API
fetch('https://your-api.com/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include', // 👈 Important to include cookies
body: JSON.stringify({
username: 'user',
password: 'pass'
})
})
.then(res => res.json())
.then(data => console.log(data))
.catch(err => console.error(err));
AXIOS
await axios.post('https://your-api.com/login', loginData, {
withCredentials: true
});