How to write an app for user authentication using JWTs
Introduction – a few words about authentication
Authentication is simply verification of identity of a user in order to access protected resources. In the case of an application, the website asks for a login and a password previously defined by the user. If the values match, it is likely that the user is who he or she claims to be. The next stage is user authorization, i.e. checking whether the user has appropriate rights to specified website resources. For example, imagine that you are a user who has an account on a given website. By logging in to this website, the authentication process is triggered. But checking whether you have the right to access a specified website resource is an authorization process.
What you can find in this guide
This guide focuses on the practical aspects of building a complete app for user authentication. It will show a method for writing a simple client using Vue.js, as well as an API that will consist of authentication based on JSON Web Tokens.
The guide is divided into two parts. In the first part, we will create APIs, while in the second part we will build a client for which we can use our APIs.
Download all the files you need to build the application!
Both parts of the guide will tell you what to focus on as you go through all the steps, alongside how to write a useful application.
Where to start – API
Our API will be a simple app written in Node.js using Express.js to handle requests, and JSON Web Tokens.
Express
Express is a minimal and flexible framework for developing node.js web applications and APIs. It contains a set of many features. Express will help us build an app for our API quickly and easily.
JSON Web Token
JSON Web Token (JWT) is an open standard (RFC 7519) defining a compact and self-contained way for secure transmission of information between parties as a JSON object.
The encoded information can be verified as it is digitally signed using a secret (with the HMAC algorithm) or a public/ private key pair using RSA or ECDSA. This is why these tokens are often used for authorization – they’re useful when a website wishes to give a party access to resources and verify this access without storing them.
JWTs consist of three parts separated by dots, and typically look like this: xxxxx.yyyyy.zzzzz.
Let’s explain the different parts:
- Header – contains information about the token: the algorithm it uses and the type of the token. The resulting JSON is changed to Base64.
- Payload – stores data, i.e. claims that relate to an individual (user) and additional data. There are three types of claims: registered, public and private claims. They store information about the token validity, creation time and user role. You may want to check the documentation to learn more. The payload is Base64Url encoded to form the second part of the JWT.
- Signature – the signature is used to verify that the message wasn’t changed along the way and, in the case of tokens signed with a private key, it can also verify that the sender is who it says it is.
For the purpose of this guide, we will use the HMAC SHA256 hashing algorithm, so the signature will be created as follows:
HMACSHA256(base64UrlEncode(header) + ’.’ + base64UrlEncode(payload), secret)
“Secret” is the signature hash password. Make sure the password is complicated and consists of many characters as, if someone cracks it, they will be able to pass off as the authorization service.
MongoDB
User data will be stored in a MongoDB database.
MongoDB is a non-relational database based on JSON documents. This means that it stores data in JSON-like documents. This makes it more distinctive and efficient. MongoDB has a large query language for filtering, searching and sorting data, no matter how extensive they may be.
Let’s explain how the API for user authentication and user management will work. If the user successfully logs in, our application will return the newly generated access token and the token to renew the first accessToken & refreshToken. Each time the user sends a protected request, i.e. a request for which they needs authentication, they will have to provide the accessToken in the request. The server will verify whether the token is valid and correct, and will return a response.
What to do when the token is generated and the user has been deleted or their access rights are changed? This is where the refresh token comes in handy. JWTs have an expiration time. In the case of our application, the access token should expire quickly, while the renewal token should have a much longer validity. In this way, we will be able to renew the token periodically and save it on the client’s side.
To understand this better, look at the diagram below:
- The client sends an email and password to the server.
- The server verifies the user’s data with those in the MongoDB database.
- If the authentication is successful, the server returns generated tokens. Specifically, it returns an AccessToken with a short validity period and a refreshToken, which should have a longer validity.
- The client stores the tokens in local memory, such localStorage.
- When executing the protected request, the client provides the accessToken in the Authorization query requests: Bearer < accessToken >.
- After receiving the JWT, the server checks if it is correct and returns a response (possibly an error if the verification fails).
- At the same time, we renew the token cyclically in the background using the refreshToken in order to verify the user’s data and rights.
We will use the jsonwebtoken module to create tokens and verify them in the application. Now that you how the API will work, we can move on to developing our application.
Configuring the application and its first launch
At the beginning, create a folder for the entire project. It will have two subdirectories, as follows:
application
- frontend // Klient
- backend // API
Then go to the backend folder and create a package.json file. Install the necessary packages there:
npm init
npm i -s express mongoose jsonwebtoken cors bcrypt
Our package.json file should look like this:
{
"name": "backend",
"version": "1.0.0",
"description": "",
"main": "app.js",
"dependencies": {
"bcrypt": "^3.0.6",
"cors": "^2.8.5",
"express": "^4.17.1",
"jsonwebtoken": "^8.5.1",
"mongoose": "^5.6.11"
},
"devDependencies": {},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC"
}
To make the work easier, we’re adding support to the ES6 syntax. To do this, update the package.json file so it looks like this:
{
"name": "backend",
"version": "1.0.0",
"description": "",
"main": "src/app.js",
"scripts": {
"start": "nodemon --exec babel-node src/app.js",
"build": "babel src --out-dir dist",
"serve": "node dist/app.js"
},
"author": "",
"license": "ISC",
"dependencies": {
"@babel/polyfill": "^7.4.4",
"bcrypt": "^3.0.6",
"body-parser": "^1.19.0",
"core-js": "^3.2.1",
"cors": "^2.8.5",
"express": "^4.17.1",
"jsonwebtoken": "^8.5.1",
"mongoose": "^5.6.2"
},
"devDependencies": {
"@babel/cli": "^7.5.5",
"@babel/core": "^7.5.5",
"@babel/node": "^7.5.5",
"@babel/plugin-proposal-class-properties": "^7.0.0",
"@babel/plugin-proposal-export-default-from": "^7.0.0",
"@babel/plugin-transform-async-to-generator": "^7.5.0",
"@babel/preset-env": "^7.5.5",
"nodemon": "^1.19.1"
}
}
This configuration of the package.json file will make it easier to work on APIs. If we decide that it is ready and working, just build your package and upload it to the server, where you will run it. However, for everything to work properly, you need to create the output file: app.js in the src. directory in the backend folder.
This is how the src/app.js file should look like:
import express from 'express'
import bodyParser from 'body-parser'
import cors from 'cors'
// Initialize app
const app = express();
app.use(cors());
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({extended: false}));
app.get('/', (req, res) => {
res.json({app: 'Run app auth'});
});
// Start app
app.listen(4200, () => {
console.log('Listen port ' + 4200);
})
This may seen complicated at first glance, but no magic is happening here. Initially, we import dependencies such as Express, body-parser and Cors. In the next line, we initialize Express.js, and the subsequent calls configure our application.
Cross-Origin Resource Sharing is a mechanism that allows you to access resources from other domains by using additional headers in the request. By default, browsers block attempts to access resources that are located under a different domain or subdomain. This is called Single-Origin Policy and is one of the basic mechanisms for ensuring user security. SPAs are particularly sensitive to this policy, as they use XHR requests to download resources from a different source. This is also the case in this example.
We also need to add the “Run app auth” response when a GET request is made on the home page. The last line defines listening for connections on the specified host and port. In our case, the port was set to 4200.
To complete the process of creating the initial application and configuration, add the .babelrc file in the backend folder. Remember how we mentioned earlier to add some @babel packages to the package.json file? Babel is a toolkit we use to convert ECMAScript 2015+ code to a backwards compatible version of JavaScript in current and older browsers and environments. To ward off any related problems in the future, we add the following configuration to our app:
This is what a .babelrc file should look like:
{
"presets": [["@babel/env",
{
useBuiltIns: "usage",
"corejs": "3.2.1"
}
]],
"plugins": [
"@babel/plugin-proposal-class-properties",
"@babel/plugin-proposal-export-default-from",
]
}
Now just install the dependencies again and run the application in development mode. Then, open the browser and check if it works at localhost:4200:
npm i
npm run start
This is what you should see:
Create a user model
Use mongoose to communicate with MongoDB. It’s an overlay providing a simple, schema-based solution for application data modeling. It includes built-in functions for projecting, validating, and querying business logic.
Initially, we need to create a schema or a model of our document. Each schema is mapped on the MongoDB collection and determines the shape of documents in that collection. In our folder, the src folder models must be created, with a users.js file in it.
Then, we import moongoose and create our user’s schema as follows:
const mongose = require('mongoose')
const Schema = mongose.Schema
const UserSchema = new Schema({
name: {
type: String,
trim: true,
required: true,
},
email: {
type: String,
trim: true,
required: true,
unique: true,
},
password: {
type: String,
trim: true,
required: true,
select: false,
},
role: {
type: String,
trim: true,
default: 'ADMIN'
}
},
{
versionKey: false
})
// type - typ
// trim - pomija białe znaki
// required - wymagany
// select - pomiń przy zwracaniu obiektu
// default - domyślna wartość
// ustawiłem ponieważ planowo rozwojowo chce dodać role dla użytkowników
Each of the objects has its own properties, briefly described above. However, what we miss here is password encryption in the database to enhance security. We’ll use bcrypt for that.
Bcrypt is a cryptographic hash function based on the Blowfish block cipher. It was created mainly for the purpose of hashing static passwords, unlike other known functions for hashing arbitrary binary data. Thanks to the use of salt, it’s resistant to rainbow table attacks. It allows you to control its computational complexity by changing the number of rounds in the hashing process (the work factor). This gives us a lot of flexibility against future attacks.
Just use the password encryption below the scheme before saving it to the collection with users. This is how the scr/models/users.js file should look like:
const mongose = require('mongoose')
const bcrypt = require('bcrypt')
const saltRounds = 10
const Schema = mongose.Schema
const UserSchema = new Schema({
name: {
type: String,
trim: true,
required: true,
},
email: {
type: String,
trim: true,
required: true,
unique: true,
},
password: {
type: String,
trim: true,
required: true,
select: false,
},
role: {
type: String,
trim: true,
default: 'ADMIN'
}
},
{
versionKey: false
})
UserSchema.pre('save', function (next) {
this.password = bcrypt.hashSync(this.password, saltRounds)
next()
})
module.exports = mongose.model('Users', UserSchema)
Now, based on this model, we’ll be able to create a user. To be more precise, the user to be created will be included in such a model. However, to be able to use this model, we need to create interfaces for our API that will have specific actions assigned to them. For example, creating a user based on a specific model, displaying a list of users or renewing tokens.
API routing
Let’s move on to creating the first route of our API. In the backend folder, you need to add a new controllers folder and with the auth.js file in it.This will contain all the functionalities related to our routes for authentication and token generation. This is what the finished file should look like:
import UserSchema from '../models/users'
import bcrypt from 'bcrypt'
import jwt from 'jsonwebtoken'
import {
TOKEN_SECRET_JWT
} from '../config'
// Validate email address
function validateEmailAccessibility(email) {
return UserSchema.findOne({
email: email
}).then((result) => {
return !result;
});
}
// Generate token
const generateTokens = (req, user) => {
const ACCESS_TOKEN = jwt.sign({
sub: user._id,
rol: user.role,
type: 'ACCESS_TOKEN'
},
TOKEN_SECRET_JWT, {
expiresIn: 120
});
const REFRESH_TOKEN = jwt.sign({
sub: user._id,
rol: user.role,
type: 'REFRESH_TOKEN'
},
TOKEN_SECRET_JWT, {
expiresIn: 480
});
return {
accessToken: ACCESS_TOKEN,
refreshToken: REFRESH_TOKEN
}
}
// Controller create user
exports.createUser = (req, res, next) => {
validateEmailAccessibility(req.body.email).then((valid) => {
if (valid) {
UserSchema.create({
name: req.body.name,
email: req.body.email,
password: req.body.password
}, (error, result) => {
if (error)
next(error);
else
res.json({
message: 'The user was created'
})
});
} else {
res.status(409).send({
message: "The request could not be completed due to a conflict"
})
}
});
};
// Controller login user
exports.loginUser = (req, res, next) => {
UserSchema.findOne({
email: req.body.email
}, (err, user) => {
if (err || !user) {
res.status(401).send({
message: "Unauthorized"
})
next(err)
} else {
if (bcrypt.compareSync(req.body.password, user.password)) {
res.json(generateTokens(req, user));
} else {
res.status(401).send({
message: "Invalid email/password"
})
}
}
}).select('password')
};
// Verify accessToken
exports.accessTokenVerify = (req, res, next) => {
if (!req.headers.authorization) {
return res.status(401).send({
error: 'Token is missing'
});
}
const BEARER = 'Bearer'
const AUTHORIZATION_TOKEN = req.headers.authorization.split(' ')
if (AUTHORIZATION_TOKEN[0] !== BEARER) {
return res.status(401).send({
error: "Token is not complete"
})
}
jwt.verify(AUTHORIZATION_TOKEN[1], TOKEN_SECRET_JWT, function(err) {
if (err) {
return res.status(401).send({
error: "Token is invalid"
});
}
next();
});
};
// Verify refreshToken
exports.refreshTokenVerify = (req, res, next) => {
if (!req.body.refreshToken) {
res.status(401).send({
message: "Token refresh is missing"
})
}
const BEARER = 'Bearer'
const REFRESH_TOKEN = req.body.refreshToken.split(' ')
if (REFRESH_TOKEN[0] !== BEARER) {
return res.status(401).send({
error: "Token is not complete"
})
}
jwt.verify(REFRESH_TOKEN[1], TOKEN_SECRET_JWT, function(err, payload) {
if (err) {
return res.status(401).send({
error: "Token refresh is invalid"
});
}
UserSchema.findById(payload.sub, function(err, person) {
if (!person) {
return res.status(401).send({
error: 'Person not found'
});
}
return res.json(generateTokens(req, person));
});
});
}
Initially, we import all dependencies, such as the schema of our user, which will be used to create it, and bcrypt, which will be used to verify the password provided by the user with that encoded in the database. The jsonwebtoken package, which is used to generate and validate our tokens, was also imported, alongside the config.js configuration file, which should be put in the backend directory, and should look like this:
module.exports = {
//MONGO CONFIG
URI_MONGO: process.env.URI_MONGO || 'mongodb://login:password@localhost:27017/DBName',
//PORT APP CONFIG
PORT_LISTEN: process.env.PORT_LISTEN || 4200,
//JWT CONFIG
TOKEN_SECRET_JWT: process.env.TOKEN_SECRET_JWT || 'jWt9982_s!tokenSecreTqQrtw'
}
This file contains the most important variables:
- URI_MONGO – data for connection with the MongoDB database (username, password and database name);
- PORT_LISTEN – the port where our application will be issued;
- TOKEN_SECRET_JWT – a secret key for encoding JWTs.
Let’s go back to our controllers/auth.js file, which is presented below:
// Validate email address
function validateEmailAccessibility(email){
return UserSchema.findOne({email: email}).then((result) => {
return !result;
});
}
This function verifies that the user with the stated email address exists in our MongoDB collection:
// Generate token
const generateTokens = (req, user) => {
const ACCESS_TOKEN = jwt.sign({
sub: user._id,
rol: user.role,
type: 'ACCESS_TOKEN'
},
TOKEN_SECRET_JWT, {
expiresIn: 120
});
const REFRESH_TOKEN = jwt.sign({
sub: user._id,
rol: user.role,
type: 'REFRESH_TOKEN'
},
TOKEN_SECRET_JWT, {
expiresIn: 480
});
return {
accessToken: ACCESS_TOKEN,
refreshToken: REFRESH_TOKEN
}
}
In the variables above, we define the objects of individual JWTs, including both accessToken and refreshToken. You can notice that their times differ from each other –the expiresIn value is expressed in milliseconds:
// Controller login user
exports.loginUser = (req, res, next) => {
UserSchema.findOne({
email: req.body.email
}, (err, user) => {
if (err || !user) {
res.status(401).send({
message: "Unauthorized"
})
next(err)
} else {
if (bcrypt.compareSync(req.body.password, user.password)) {
res.json(generateTokens(req, user));
} else {
res.status(401).send({
message: "Invalid email/password"
})
}
}
}).select('password')
};
The user login interface will perform the loginUser action. First, we check in the database whether the user with the stated mail address exists. If so, we validate its password using bcrypt, and then return the newly generated res.json tokens (generateTokeny (req, user)):
// Verify accessToken
exports.accessTokenVerify = (req, res, next) => {
if (!req.headers.authorization) {
return res.status(401).send({
error: 'Token is missing'
});
}
const BEARER = 'Bearer'
const AUTHORIZATION_TOKEN = req.headers.authorization.split(' ')
if (AUTHORIZATION_TOKEN[0] !== BEARER) {
return res.status(401).send({
error: "Token is not complete"
})
}
jwt.verify(AUTHORIZATION_TOKEN[1], TOKEN_SECRET_JWT, function(err) {
if (err) {
return res.status(401).send({
error: "Token is invalid"
});
}
next();
});
};
// Verify refreshToken
exports.refreshTokenVerify = (req, res, next) => {
if (!req.body.refreshToken) {
res.status(401).send({
message: "Token refresh is missing"
})
}
const BEARER = 'Bearer'
const REFRESH_TOKEN = req.body.refreshToken.split(' ')
if (REFRESH_TOKEN[0] !== BEARER) {
return res.status(401).send({
error: "Token is not complete"
})
}
jwt.verify(REFRESH_TOKEN[1], TOKEN_SECRET_JWT, function(err, payload) {
if (err) {
return res.status(401).send({
error: "Token refresh is invalid"
});
}
UserSchema.findById(payload.sub, function(err, person) {
if (!person) {
return res.status(401).send({
error: 'Person not found'
});
}
return res.json(generateTokens(req, person));
});
});
}
This functionality will be used to verify the appropriate token. Note that both the first token and second token should have Bearer <jwt_token> added at the start. If the token is successfully transferred, the verify function will check if it is correct, using a secret key:
// Controller create user
exports.createUser = (req, res, next) => {
validateEmailAccessibility(req.body.email).then((valid) => {
if (valid) {
UserSchema.create({
name: req.body.name,
email: req.body.email,
password: req.body.password }, (error, result) => {
if (error)
next(error);
else
res.json({
message: 'The user was created'})
});
} else {
res.status(409).send({message: "The request could not be completed due to a conflict"})
}
});
};
To be able to create new users, you must also add the CreateUser function. It will be used to create a new user based on the previously built schema.
Now it’s time to create API routing. To do so, create the routes.js file in the src folder:
import express from 'express'
import authController from './controllers/auth'
const router = express.Router();
router.post('/login', authController.loginUser);
router.post('/refresh', authController.refreshTokenVerify);
// secure router
router.post('/register', authController.accessTokenVerify, authController.createUser);
module.exports = router;
Then we need to import the controller with functions for validation, login, token renewal and user set-up. Note that the interface/ register is protected. We can’t create it unless we are properly authenticated and our token is valid and correct.
Finally, we need to create an interface to download the list of users in the system. For this reason, you need to add the users.js file to the controllers folder
import UserSchema from '../models/users'
// Controller get users list
exports.getUserList = (req, res, next) => {
UserSchema.find({}, {}, (err, users) => {
if (err || !users) {
res.status(401).send({message: "Unauthorized"})
next(err)
} else {
res.json({status: "success", users: users});
}
})
}
and update the routes.js file.
import express from 'express'
import authController from './controllers/auth'
import usersController from './controllers/users'
const router = express.Router();
router.post('/login', authController.loginUser);
router.post('/refresh', authController.refreshTokenVerify);
// secure router
router.get('/users', authController.accessTokenVerify, usersController.getUserList);
router.post('/register', authController.accessTokenVerify, authController.createUser);
module.exports = router;
In this way, if we are properly authenticated, we’ll be able to download a list of existing users.
Database configuration
In the next step, we start the MongoDB database. You can use a cloud platform, such as AWS, or install it locally on your computer based on Docker. In the following example, Docker was used to run a database in the container.
You need to add the docker-compose.yml file to the folder with the backend application. The file looks as follows:
version: '3.1'
services:
mongodb:
container_name: mongodb
image: 'bitnami/mongodb:latest'
ports:
- 27017:27017
environment:
- MONGODB_USERNAME=admin // login user
- MONGODB_PASSWORD=example // password user
- MONGODB_DATABASE=authDB // nazwa naszej bazy
- MONGODB_ROOT_PASSWORD=rootExample // hasło użytkownika root
Now just run docker-compose up-d, and the docker will download all dependencies and run the database in the background. If you want to verify whether the database has started, use docker ps –a. You should see information that the container is running.
Connecting to the database and using routing
Now we can go back to the app.js file, use routing and define the connection to our database. Here’s what the updated file should look like, using variables from the config.js file and all the previous steps:
import express from 'express'
import bodyParser from 'body-parser'
import cors from 'cors'
import mongoose from 'mongoose'
import routes from './routes'
import config from './config'
import { initializeData } from './seed/user-seeder'
// Initialize app
const app = express();
app.use(cors());
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({extended: false}));
app.get('/', (req, res) => {
res.json({app: 'Run app auth'});
});
// Connect to MongoDB
mongoose.connect(config.URI_MONGO, {
useCreateIndex: true,
useNewUrlParser: true
}).catch(err => console.log('Error: Could not connect to MongoDB.', err));
mongoose.connection.on('connected', () => {
initializeData()
console.log('Initialize user')
});
mongoose.connection.on('error', (err) => {
console.log('Error: Could not connect to MongoDB.', err);
});
// Routes app
app.use('/', routes);
// Start app
app.listen(config.PORT_LISTEN, () => {
console.log('Listen port ' + config.PORT_LISTEN);
})
Remember to update your user details, password and database name in config.js:
//MONGO CONFIG
URI_MONGO: process.env.URI_MONGO ||
'mongodb://admin:example@localhost:27017/authDB'
At the stage type, the connection to the database has been defined. When the connection is successful, the initializeData function is called. Note that our interface for creating the user is protected, so the first test user must be created in the database when connecting to it. For this reason, in the src folder, we create a seed folder with the user-seeder.js file. Test user creation will be defined there. This is what the finished file should look like:
import UserSchema from '../models/users'
async function isUsersExist() {
const exec = await UserSchema.find().exec()
return exec.length > 0
}
// Initialize first user
export const initializeData = async () => {
if(!await isUsersExist()) {
const user = [
new UserSchema({
role: "ADMIN",
name: "admin",
email: "admin@admin.com",
password: "admin"
})
]
let done = 0;
for (let i = 0; i < user.length; i++) {
user[i].save((err, result) => {
done++;
})
}
}
}
It’s time to launch our API. First, make sure that the database container is still open. After running the command in the npm run start terminal, the following view should appear:
Now check if the appropriate user has been created in the database:
Now you can make sure that our API is actually working. In the case described, postman was used to check queries.
Inquiry/ login – we should receive JWTs in response:
Then check the protected / users query, which should return a list of users. Remember to pass the accessToken. Finally, we get a list of users:
End
After performing all the above steps, we’re sure that our API works. Now you can use the created app to authenticate users in the selected client. Note why two tokens have been created, rather than the usual one. This is to maintain control over access to our application. Frequent token refreshing will allow us to verify user roles and permissions, and to control user presence in the database in case another user deletes them or otherwise changes their access rights.
Check my backend directory, where you will find all the necessary files to build the application at the stage discussed in this article. In the second part of this guide, you’ll get access to the frontend directory.
The following describes the correct behavior in the client:
- After successful authentication, save the token in e.g. localStorage.
- Tokens must be renewed periodically, depending on the validity of the accessToken.
- Each secure request should contain a Bearer <accessToken> header.
- Create middleware that will check if it’s possible to enter the appropriate address in the client.
- If token renewal or authentication fails, access must be denied and localStorage cleared.
If you encounter problems implementing the API in the client, in the second part of the guide, you will learn how to use our API in a simple client based on the Vue.js framework.
Would you like to find more about this? Read the second part!
Feeling inspired? Check out our offers and join the team!