Token-Based Authentication
A thorough guide to implementing user authentication in your application
In this tutorial, we will cover token-based authentication
.
The general concept behind token-based authentication is the ability for users to obtain a token in exchange for their user credentials. This token authorizes the user to access protected resources without the need for querying for credentials.
Once the token is obtained, the user is now authenticated and can access protected resources for a specified amount of time.
In other words…
Typically when you check into a hotel you are given a room key. Think of the room key as your token. This room key gives you the user, access to the protected resources, such as the gym and the pool.
You are authorized to use the gym and pool for a specified amount of time: the duration of your staty at the hotel. Once you have checked out of the hotel, you are no longer authorized to access those protected resources.
What is a cookie? 🍪
Cookies are small pieces of data sent from a website and are stored in the user’s web browser while the user is browsing that website. Every time the user loads that website back, the browser sends that stored data back to website or server, to distinguish user’s previous activity.
Getting Started
In this tutorial, the emphasis is on token-based authentication therefore, we will not be using a database. In another tutorial, I will revisit this topic to create a MERN Stack Web application using token-based authentication.
Side note…
I wrote this post from a particular perspective. At the time of this post, I was a teaching assistant for a coding bootcamp and noticed a lot of students struggling debugging their console errors. In this guide, we analyze and resolve common errors. With that said…
Let’s Get Started
The 3 most important tools we will be using for this project are the following:
bcrypt: A library to help you hash passwords.
json-webtoken: A JSON object that is defined as a safe way to represent a set of information between two parties.
dotenv: A zero-dependency module that loads environment variables from a .env
file into process.env
.
cookie-parser: Parse Cookie
header and populate req.cookies
with an object keyed by the cookie names. Optionally you may enable signed cookie support by passing a secret
string, which assigns req.secret
so it may be used by other middleware.
Now, lets set up our app:
Assuming you have Node.js and Git installed, run the following commands in your terminal to get started.
$ cd Desktop
$ mkdir authentication && cd authentication
$ git init
$ npm init -y
$ npm i express bcrypt dotenv jsonwebtoken nodemon morgan cookie-parser uuid
$ mkdir controllers utilities routes config models && touch server.js
$ touch config/dbUsers.json
$ echo -e 'node_modules \n.env'> .gitignore
$ echo -e 'PORT=portNumber \nSALTROUNDS=number \nSECRET=string'> .env
In this tutorial config
and models
is not needed. However, if we were using a database the config directory would contain our database connection and our model's directory would contain the models of our data.
In your .env
file, change the following:
portNumber to a port number, typically 3001.
number to an integer.
string to a "string", this is just a secret signature we will user later on.
The last change is to update our package.json file and add a script for nodemon. In package.json add the following:
"scripts": {
"nodemon": "nodemon server.js"
}
Heres a summary of what we’ve just done.
- made a directory called authentication on your desktop and navigated to the directory
- we initialize the project with
git
- we initialize the project with a
package.json
file with default settings - we install our dependencies
- we create our empty server file and the needed directories
- created a
Users.json
file, that would contain our mock database of users - created a file called
.ignore
and write names of files/folders to ignore - create a file called
.env
that will hold theenvironment vairables
that will be used throughout our project by usingprocess.env
.
Let's start the server.js
file by running npm run nodemon
from the terminal.
let's add some code to our express app. I will not explain in detail what each line of code does, however, the answer to these question can quickly be found with a bit of google foo! Moving on…
// In server.jsconst { config } = require('dotenv');
const express = require('express');
const app = express();
const logger = require('morgan');
const cookieParser = require('cookie-parser');
const userRoutes = require('./routes/user');
// LOADS ENVIRONMENT VARIABLES
config({ debug: process.env.DEBUG });
// LOGS HTTP METHODS
app.use(logger('dev'));
// PARSES JSON
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
// PARSES COOKIES
app.use(cookieParser(process.env.SECRET));
// ROUTES
app.use('/users', userRoutes);
// LISTENS ON PORT
app.listen(process.env.PORT, () =>
console.log(`App running on http://localhost:${process.env.PORT}`)
);
Congrats! We have our first error! 🎉
We’ll get to that soon enough. But before then, a few things to note. In our code, we are using destructuring:
es5
require('dotenv').config({ debug: process.env.DEBUG });es6const {config} = require('dotenv');
config({ debug: process.env.DEBUG });
Now, for the Error… 🧐
At first, this can be overwhelming, but if we look closely…
- What does our error say?
Error: Cannot find module './routes/user'
- What does this error mean?
Cannot find our module userRoutes
- Why are we getting this error?
ur we are requiring a variable that references a file which does not exist yet
- How can we fix this error?
Create a file called user.js inside of the routes directory
Side Note: what does app.use('/users', userRoutes)
do?
app.use('/users', userRoutes)
prefixes all of our routes inside the routes/user.js with /users
.
// In routes/user.jsconst router = require('express').Router();
const {login, logout, signup, cookieCheck, getUsers} = require('../controllers/user');router.get('/all', getUsers);
router.get('/authorized', cookieCheck);
router.post('/login', login);
router.get('/logout', logout);
router.post('/signup', signup);module.exports = router;
What we are doing here is requiring & exporting
the, and using our controller functions
to handle our routes
. These functions getUser, cookieCheck, login, logout, signup
are used to do something on these particular routes. We will review these in a second. But first…
We now have another Error! 🤭
Take some time to think about how to answer the following questions:
- What does our error say?
- What does this error mean?
- Why is this error happening?
- How can we fix this error?
Answer: our controller functions are not defined.
To resolve this error, let's create a file called user.js
inside of the controllers
directory and insert the following code:
// In controllers/user.jsconst Users = require('../config/dbUsers.json');const getUsers = async (req, res) => {
res.json(Users);
};
const login = async (req, res) => {
res.send('you hit the login route');
};
const logout = async (req, res) => {
res.send('you hit the logout in route');
};
const signup = async (req, res) => {
res.send('you hit the signup in route');
};
const cookieCheck = async (req, res) => {
res.send('you hit the authorized route, we will need to check your cookies');
};module.exports = { login, signup, logout, cookieCheck, getUsers };
Oh look, another Error, yay!🤓 🎉 This is the last one I promise 🤥
How do we fix this one? no clue?
Let’s google it! 🧐 …Wait, what do I google?
Answer: SyntaxError: Unexpected end of JSON input
…If you didn’t find the answer, here is why we get the error:
We do not have any data in our dbUsers.json file
How to Fix?
Open our config/dbUsers.json
file and copy 🍝 our mock data.
Make sure the data is properly formatted as JSON
by using double quotes " "
// In config/dbUsers.json {
"username": "tyroo",
"hashedPassword": "$2b$10$lprsK4kY7fWm1jv/a97jLO17n5NIm1qLmEoN/lO6Ip2curY.jSnSm",
"plainTextPassword": "tyrooTheKangaroo"
},
{
"username": "hanna",
"hashedPassword": "$2b$10$CsQBUeevFRweWyIh7uYorutAmBFiy6d1/brgTWUP85U0zh3r3nxr6",
"plainTextPassword": "hannaBanna"
},
{
"username": "zane",
"hashedPassword": "$2b$10$q1akMvEKFrDF34F6Xd.kHe1.IN27b3zUYBHn8TsnY3oZ9Su.2hkoG",
"plainTextPassword": "zaneTheMane"
}
]
For demonstration purposes, we’ve hardcoded plainTextPasswords
and hashedPasswords
for testing our mock users. However, we will create new users and hash their passwords.
Congratulations!
You’ve made it this far. At this point, it would be a good idea to start testing our routes and ensure everything is working correctly. If you have not done so already, go ahead and download POSTMAN.
🚨Before you start testing routes!!
Every time you save your JavaScript files nodemon
will restart the application. Therefore, our newly created users are deleted every time we save.
🛣 Here’s what our routes look like, the corresponding methods, and the response:
| METHOD | ROUTE | RESPONSE |
| ------ | ----------------- | ------------------------------- |
| GET | /users/logout | you hit the logout route |
| GET | /users/all | displays our mock database |
| GET | /users/authorized | you hit the authorized route... |
| POST | /users/login | you hit the login route |
| POST | /users/signup | you hit the signup route |
📂 Your application structure should look like this:
You can ignore the
images
directory and theREADME.md
those were included in the image while testing the tutorial.
Now that all our routes are defined, let write the logic for hashing passwords and start testing using POSTMAN.
🧐📝 Testing our Routes in POSTMAN
Let’s open POSTMAN and make our first request.
TODO:
- set our HTTP METHOD to
GET
- set our route name to
http://localhost:PORT/users/all
- ensure the
PORT
is the correct number.Check your .env
- finally, click send
Notice on our GET /users/all
route, our passwords are stored as plain text. What we need to do is store the plainTextPasswords
as hashedPasswords
using bcrypt.
Let's create a file called passwordService.js
in our utility directory and insert the following code:
// In utilities/passwordService.jsconst bcrypt = require('bcrypt');
const { SALTROUNDS } = process.env;module.exports = {
hashPassword: async myPlaintTextPassword => {
try {
let hash = await bcrypt.hash(myPlaintTextPassword, parseInt(SALTROUNDS));
return hash;
} catch (err) {
if (err) throw err;
}
},
checkPassword: async (myPlaintTextPassword, hash) => {
try {
let isMatched = await bcrypt.compare(myPlaintTextPassword, hash);
return isMatched;
} catch (err) {
if (err) throw err;
}
}
};
- What we are doing here is requiring the bcrypt package and using our
environment variable SALTROUNDS
from our.env
file. - Exporting two functions,
hashPassword
andcheckPassword
which will be used in ourlogin and register
routes. This would typically be done on your user model but because we are not using an official database we will use them directly on our routes.
hashPassword
: is anasync function
that takes aplain text password
as anargument
, uses thebcrypt.hash method
which takes aplain text as its first argument
and the number ofsalt rounds as its second argument
andreturns the hashed password
.checkPassword
: is anasync function
that takes aplain text password as its first argument
and ahashed password as its second argument
and uses thebcrypt.compare method
which takes aplain text as its first argument
and thehash as its second argument
andreturns the hashed password
.
Although our environment variable for
SALTROUNDS
is a number, we still need to parse our value as an Integer.
SaltRounds…Ummm what's that? 🤯
SALTROUNDS: “The Cost Factor”
The cost factor
controls the amount of time needed to calculate a single BCrypt hash. The higher the cost factor, the more hashing rounds
is done. Increasing the cost factor by one doubles the necessary time. The higher the cost factor, the more difficult a hash is susceptible to brute-forcing.
- The salt is a random value and should differ for each calculation. The results should hardly ever be the same, even for equal passwords.
- The salt is usually included in the resulting hash-string in a readable form. So with storing the hash-string you also store the salt.
- A cost factor of 10 means that the calculation is done 2¹⁰ times which is about 1000 times. The more rounds of calculation you need to get the final hash, the more CPU/GPU time is necessary. This is no problem for calculating a single hash for a login, but it is a huge problem when you brute-force millions of password combinations.
We have our function, how do we use it?
For this exercise, we will modify each route individually, so be sure to always copy and paste the newconstant variables
located at the top of each code snippet.
Insert the following code for the signup
route:
// In controllers/user.jsconst uuid = require('uuid/v4');
const Users = require('../config/dbUsers.json');
const { hashPassword } = require('../utilities/passwordService');const signup = async (req, res) => {
try {
let newUser = {
id: uuid(),
password: req.body.password,
hashedPassword: await hashPassword(req.body.password),
username: req.body.username
};
Users.push(newUser);
res.status(200).redirect('/users/all');
} catch (err) {
if (err) throw err;
}
};
What we are doing here is requiring a package uuid
which creates a unique id for our newUser object. Then we use our hashPassword
middleware function and pass our password from the post in ourreq.body
. We then push our newUser
to our dbUsers.json
file, send a status code to our client and redirect to our /users/all
route.
Test your /users/signup route
using POSTMAN
- HTTP METHOD:
POST
, - ROUTE:
http://localhost:yourPortNumber/users/signup
- click on
body
- select
x-www-form-urlencoded
- finally, add a key-value pair of
username, password
Once you submit your POST
you should have a new user in the response
Wallah! You should now be able to create a new user which should have an id, username, password, and a hashedPassword
field.
Next, we will add functionality to our login route
using our checkPassword middleware function
from utilities/passwordService.js
.
Add the following code to our login
route:
// in controllers/user.jsconst uuid = require('uuid/v4');
const Users = require('../config/dbUsers.json');
const { hashPassword, checkPassword } = require('../utilities/passwordService');const login = async (req, res) => {
try {
let user = Users.find(user => user.username === req.body.username);
let isMatch = await checkPassword(req.body.password, user.hashedPassword);
if (user) {
if (isMatch) {
res.status(200).redirect('/users/authorized');
} else {
res.send('sorry password did not match');
}
} else {
res.send('sorry username does not match');
}
} catch (err) {
if (err) throw err;
}
};
Test your login route
using POSTMAN
- request type is POST
- route is http://localhost:yourPortNumber/users/login
- click on body
- select www-form-urlencoded
- add key-value pair of username & password
- try using incorrect credentials
- try using correct credentials
What we are doing in our login
route is returning the first user in our “database” that matches
the req.body.username.
Then we are using our checkPassword
function which returns a Boolean value
. The check password function checks if the req.body.password
matches the hashedPassword
from our user
.
Now we conditionally check if a user exists. If the user exists, we then check if the password matches. If all goes well, we send a 202 status code
and redirect
to our authorized
route. Otherwise, we send errors
to the client
explaining what went wrong. In a real application, your message would look more something like: “sorry, the credentials you have entered are incorrect please try again”.
We now need to implementjson-webtokens
and 🍪’s.
Let's create and open a file utilities/tokenService.js
. In this file, we need the jsonwebtoken
package and our SECRET
environment variable. We will then create two functions, createToken
and isValidToken
.
// In utilities/tokenService.jsrequire('dotenv').config();
const jwt = require('jsonwebtoken');
const { SECRET } = process.env;module.exports = {
createToken: async user => {
try {
let token = await jwt.sign(
{ user, exp: Math.floor(Date.now() / 1000) + (60 * 60) },
SECRET
);
return token;
} catch (err) {
if (err) throw err;
}
}, isValidToken: async token => {
try {
let decoded = await jwt.verify(token, SECRET);
return decoded;
} catch (err) {
if (err) throw err;
}
}
};
What we expect the code above to do is create a token
by using jwt to sign a token. Passing in an object with our expected user, and an expiration time, and ourSECRET
environment variable. Then we return the token. Additionally, we verify our token
using jwt.verify
which takes an expected token string as its first argument and our SECRET
environment variable.
Now in our controllers/user.js
file lets refactor our code and each route to use our token service and cookies.
Updating our Route handlers, one by one
🚨👀🧐MAKE SURE TO TEST EVERY ROUTE!!
// In controllers/user.jsconst uuid = require('uuid/v4');
const Users = require('../config/dbUsers.json');
const { hashPassword, checkPassword } = require('../utilities/passwordService');
const { createToken, isValidToken } = require('../utilities/tokenService');
const cookieOptions = {
expires: new Date(Date.now() + 900000),
httpOnly: true,
// secure: true, on deployment for https
signed: true
};
In the above snippet:
- require UUID which automatically generates unique id’s for our users.
- require our Users mock database.
- require our utilities for our password Service and token Service.
- create an object called cookie options which will be explained below.
// In controllers/user.jsconst getUsers = async (req, res) => {
try {
res.json(Users);
} catch (err) {
if (err) throw err;
}
};
In the above snippet:
- we create a getUsers handler that sends our Users array to the client.
// In controllers/user.jsconst login = async (req, res) => {
try {
let user = Users.find(user => user.username === req.body.username);
let isMatch = await checkPassword(req.body.password, user.hashedPassword);
if (user) {
if (isMatch) {
let token = await createToken(user);
console.log('token', token);
res.cookie('token', token, cookieOptions).redirect('/users/authorized');
} else {
res.send('sorry password did not match');
}
} else {
res.send('sorry username does not match');
}
} catch (err) {
if (err) throw err;
}
};
In the above snippet, we create a login handler which:
- Finds a user by its username
- Checks the submitted password to our users hashedPassword
- Then we check if the user exists if true…
- We then check if the password matches, if true…
- Create a token with the user object.
- Create a cookie called token, pass in our token, and our cookieOptions and redirect to authorized route.
Notice, we console.log("token", token);
. Copy 🍝 this token from the terminal and visit JWT. Scroll down to the debugger and past your token in the encoded area.
Our token, when decoded contains a header, payload, and signature. Where the header contains the algorithm and type of token, the payload contains the data, and the signature verifies if our signature matches our SECRET
environment variable.
JWT
Encoded
Decoded
Don’t confuse this with encryption. What we’ve done is stored our credentials in an object (json web token) which is an additional layer of security.
// In controllers/user.jsconst logout = async (req, res) => {
try {
res.clearCookie('token').redirect('/users/all');
} catch (err) {
if (err) throw err;
}
};
In the above snippet, we simply remove our cookie from the client and redirect to our /users/all
route. Therefore, without the cookie, our user is signed out and not authorized.
// In controllers/user.jsconst signup = async (req, res) => {
try {
let newUser = {
id: uuid(),
password: req.body.password,
hashedPassword: await hashPassword(req.body.password),
username: req.body.username
};
Users.push(newUser);
let token = await createToken(newUser);
console.log(token);
res
.cookie('token', token, cookieOptions)
.status(200)
.redirect('/users/authorized');
} catch (err) {
if (err) throw err;
}
};
In the above, our signup handler:
- creates a new user object and pushes the new user to our Users mock database.
- creates a token and cookie with our newUser object just like in our login route.
I know I said last error… 🤥but hey we’re all 👨💻and we’re not perfect.
// In controllers/user.jsconst cookieCheck = async (req, res) => {
try {
res.send(
'you hit the authorized route, we will need to check your cookies'
);
} catch (err) {
if (err) throw err;
}
};
For now, our cookieCheck handler
just sends a message to the client. What we want our cookieCheck handler to do is get
the token
from the signed cookie
and if it exists to validate the token and pull out the user's credentials.
Next, find a user
in our mock DB using the credentials from the token
and send them to the client. Of course, handle all our errors. We will log specific errors for this exercise but in a real application, these messages should not be here for obvious reasons.
Add the following code and then we will test all of our routes and ensure we are the correct user.
// In controllers/users.jsconst cookieCheck = async (req, res) => {
const { token } = req.signedCookies;
if (token) {
try {
let {
user: { username, hashedPassword }
} = await isValidToken(token);
let user = Users.find(user => user.username === username);
res.send({ username: user.username, password: hashedPassword });
} catch (err) {
if (err) throw err;
}
} else {
res.send({ message: 'Sorry your token has expired.' });
}
};
Using POSTMAN, we will test the following routes:
| METHOD | ROUTE | RESPONSE |
| ------ | ----------------- | ------------------------------- |
| GET | /users/logout | clears cookies and redirects |
| GET | /users/all | displays all users |
| GET | /users/authorized | validates token client |
| POST | /users/login | verifies username/password |
| POST | /users/signup | creates new user inside cookie |
Recap:
- Learned what is a Cookies
- Understand how to use a JSON Web Token
- Learned how to read, research and handle errors
If you enjoyed the article, throw it a 👏🏼 and let me know! Thanks for reading and hope to bring more awesome content to your screens!