Skip to content

krokyk/jwt-nodejs-express-tutorial

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

62 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

JWT + Node.js + Express Tutorial

Simple example on how to work with JWT using Node.js and Express.js with VSCode running in WSL with Ubuntu distro.

Disclaimer: I was heavily inspired by this great video tutorial by Web Dev Simplified

Prerequisites

Note: Actually you don't need to use VSCode or WSL, it's up to you. I chose this combination because:

  • VSCode is a great IDE and works very well with Node.js
  • It has a very powerful integrated terminal
  • I like Bash shell more than Windows PowerShell or Cmd

Setting up the Workspace

There are several ways to open the WSL prompt, one is to hit WIN+R to open the Run dialog and typing wsl into the text box followed by ENTER. A WSL prompt should open like this:

WSL Prompt

Next, you're going to create a vscode dir in your user home directory and clone this repository into it. In WSL prompt, type:

cd ~ && mkdir vscode && cd vscode/
git clone https://github.com/krokyk/jwt-nodejs-express-tutorial.git

πŸ’‘ TIP: You can also clone the repository using SSH but you need to setup your SSH keys first:

git clone git@github.com:krokyk/jwt-nodejs-express-tutorial.git

πŸ’‘ TIP: You can also just download the ZIP archive of this project from github and unzip it into the vscode dir (rename the folder to jwt-nodejs-express-tutorial if necessary).

Now, launch VSCode in the newly created dir

code jwt-nodejs-express-tutorial/

You can close WSL prompt now. You will be working exclusively in VSCode from now on.

Initial Content of the Project

You can ignore the images dir (it contains images for this readme) and .vscode (workspace configuration for this project) but take a look into the one called chapter-src.

This dir holds files arranged into subdirs. Each subdir reflects a step in the project's "evolution" as the files are created/changed during the course of this tutorial. These subdirs are numbered and each subdir represents a certain state of the project content. The subchapters are numbered as well and their number corresponds to the subdir names. After you finish one subchapter, your files in the root of the project should have the same (or very very similar) content as those you find in chapter-src under subdir with the same number as the chapter you just finished.

πŸ’‘ TIP: It's helpful to compare the contents of the project dir with each of the subdirs in chapter-src dir as you go through the tutorial chapters to make sure you did not make any mistake. You can use any tool that is able to compare the directory contents and easily display changed files and then compare those files to see what the change from that chapter is. My tool of choice for that is Beyond Compare.

01 - Initialize the Project

If you do not have a terminal opened yet, hit CTRL+SHIFT+` or go to the Terminal β†’ New Terminal in the menu bar. Type this in your terminal:

npm init -y

ℹ️ INFO: File package.json was created with some default values derived from our current content.

02 - Install Required Libraries

npm install express jsonwebtoken dotenv
  • express for our server code and API endpoints
  • jsonwebtoken for working with JWT
  • dotenv to store our sensitive and configuration stuff inside the .env file (like secrets or ports)

Install the development dependency nodemon which will automatically restart the server as you make changes to the code:

npm install --save-dev nodemon

ℹ️ INFO: Another file called package-lock.json and whole subdir called node_modules were created.

03 - Create Project Files

touch apiServer.js && touch .env
  • apiServer.js will contain our API code
  • .env will contain our configuration

Inside the package.json, hook the nodemon to run newly created apiServer.js:

  "scripts": {
    "apiStart": "nodemon apiServer.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },

You can now try to run the apiServer.js with nodemon:

npm run apiStart

Nodemon starts

Now, without quitting the server, try adding

console.log("Hi!")

to the apiServer.js and you should see the output in the terminal immediately after you save the file, because nodemon is monitoring it.

Nodemon refreshes the server

04 - Let’s Create API Server

Add API_SERVER_PORT env variable to .env file:

API_SERVER_PORT=3000

And this to the empty apiServer.js:

require("dotenv").config()
const PORT = process.env.API_SERVER_PORT

const express = require("express")
const app = express()

app.listen(PORT)

It will do nothing yet, just listen on the port configured via .env.

05 - Return Some Data

Create a GET/posts endpoint in apiServer.js that returns a simple JSON object containing 2 posts:

const posts = [
    {
        author: "Jane",
        title: "Post 1"
    },
    {
        author: "John",
        title: "Post 2"
    }
]

app.get("/posts", (req, res) => {
    res.json(posts)
})

You can test the endpoint by opening http://localhost:3000/posts in your browser. But we're going to...

06 - Use the REST Client Extension to Display Data

It can run requests defined in the *.rest files. File can contain multiple requests separated by three (or more) ###. Create file requests.rest in your project by running this in terminal:

touch requests.rest

With following content:

#######################################
GET http://localhost:3000/posts

In the editor, it will look like this:

requests.rest

Now, you can click on Send Request and the resulting response will open in the new split window:

Response

This is all nice, but what if you don't want to display the whole content to anyone, but only the content they are authors of? You need to add some authentication and authorization to the server to do that.

07 - Add an Authentication Endpoint

  • Create a POST/login endpoint in apiServer.js.
  • It's POST, because you are going to be sending data (credentials) to the server.
  • Since the request body will be in a JSON format, you need to tell that to the server by configuring the middleware, so it understands the body of such requests. To configure Express.js in such a way use this construct:
    app.use(express.json())

    ℹ️ INFO: In short, middleware are those methods/functions/operations that are called between receiving the request and sending back the response.

  • The endpoint should take care of the authentication of the user, but this is not in scope of this tutorial. You can just assume the authentication was successful and the user really is who he claims to be. Add this to the apiServer.js:
    app.post("/login", (req, res) => {
      // Authenticate user
    })
  • So take the username from the request body and use it in creation of user object that will be stored inside the generated token. Add this to the POST/login endpoint method:
      const username = req.body.username
      const user = { name: username }
  • Import the jsonwebtoken library.
    const jwt = require("jsonwebtoken")
  • To create the token and send it back to the client, add this to the POST/login endpoint method:
      const accessToken = jwt.sign(user, process.env.ACCESS_TOKEN_SECRET)
      res.json({ accessToken: accessToken })
  • Store the secret inside the .env file. You can use Node.js crypto library to generate a strong secret (e.g. 64 random bytes converted to a hexadecimal string). Paste this into terminal (yes, you can paste the whole line, including the comment πŸ˜‰):
    node -p "require('crypto').randomBytes(64).toString('hex')" # -p prints out the evaluated input
    Each time you run that, a new random string is generated. Create ACCESS_TOKEN_SECRET environment variable in the .env file with that value, e.g.:
    ACCESS_TOKEN_SECRET=9fef66c25daba5b9a28a59f82e4bd799c83d891f4dae047c27c60796c0b5a9732cf66b87c21836f8df1ef8580de72b4c5d1197a6e811063d3b1ed03ed4fb8bb7

    ❗ IMPORTANT: Don't forget to restart the server so that the new environment variable is available to the server process. You can do so by typing rs in the terminal where nodemon is running, or simply by exiting and starting the server again.

08 - Test the POST /login Endpoint

Add the POST/login request to the requests.rest:

#######################################
POST http://localhost:3000/login
Content-Type: application/json

{
    "username": "Jane",
    "password": "abcd"
}

The response will look like this:

Response

That gibberish is actually our access token holding the information we've put in it (our user JSON object) along with some other stuff, added automatically by the jsonwebtoken library.

πŸ’‘ TIP: You can actually head out to the official JWT page and paste your token there to see what's in it.

09 - Verify the Access Token in the Middleware

  • in apiServer.js create a middleware function (or route handler) called verifyToken
    function verifyToken(req, res, next) {
        
    }
  • add it to the GET/posts endpoint to the chain of our route handlers
    app.get("/posts", verifyToken, (req, res) => {
        res.json(posts)
    })

Inside verifyToken function:

  • extract the Authorization header from the request
        const authHeader = req.headers["authorization"]
  • Verify there is actually such header and if yes, get the token from it. Since Authorization header value will be in format Bearer <token>, split it (on the space character) and take the second array element from it
        const token = authHeader && authHeader.split(" ")[1]
  • if the header is not there or the value of the header is malformed somehow (e.g. there's no space between Bearer and <token>), return 401 Unauthorized HTTP status
        if (token == null) return res.sendStatus(401)
  • otherwise, proceed to the verification of the token itself. If there's an error, return 403 Forbidden HTTP status. If not, pass the control to the next route handler in sequence
        jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, obj) => {
            if (err) return res.sendStatus(403)
            next()
        })

10 - Test the GET /posts Endpoint

Just send GET/posts request from the requests.rest file as is. You should see Unauthorized in the response.

Response

Now add an Authorization header to the request in the line below the GET/posts request with a wrong token:

#######################################
GET http://localhost:3000/posts
Authorization: Bearer thisTokenIsObviouslyWrong

and hit Send Request. You'll get Forbidden status in the response.

Response

Get the correct token by sending POST/login request, copy it and add it to the header:

#######################################
GET http://localhost:3000/posts
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSmFuZSIsImlhdCI6MTYzMzUyNjYwMX0.VD8o8dGKben_XdDxKt4oEmkMzJeQrWhk8i4bqNVa2-Q

πŸ’‘ TIP: You will certainly get a different token than in the code snippet above, because it changes everytime the timestamp changes on your system. The reason is that in the token's payload the "iat" JSON field (generated automatically by the jsonwebtoken library) contains the current timestamp. It stands for Issued At and holds the token creation timestamp (in seconds). It's one of the standard JWT claims.

Now the response body should look the same as in chapter 06, i.e. it should contain the 2 posts you created earlier. So how do you filter the data in the response based on the author?

11 - Filter the Data Based on the Token

In order to do that, you just need to use the information that is inside the token's payload, field name. In the verifyToken method, rename the too-generic obj to payload.

πŸ’‘ TIP: Use console.log(payload) to see what's in there whenever this route handler is ivoked. You should see something like this in the terminal:

  { name: 'Jane', iat: 1633526601 }

Just before passing the processing of the request to the next route handler, add a custom JSON object user to the request. This object has a single field name with value coming from the token payload's field name:

    jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, payload) => {
        if (err) return res.sendStatus(403)
        req.user = { name: payload.name }
        next()
    })

In the last route handler in GET/posts endpoint, instead of returning the full posts array, filter it based on post's author field:

    res.json(posts.filter(p => p.author === req.user.name))

When you send the GET/posts request (in requests.rest file) now, you will no longer see all posts, just those where author field is the same as name in the token.

Response

πŸ’‘ TIP: Replace the token in GET/posts request with John's token and see what posts are returned.

12 - Working with JWTs across Different Servers

To see how easy it is to work with the token across different servers (meaning you login on server "A" and display the posts on server "B"), make a copy of the apiServer.js and call it authServer.js. Run this in the terminal:

cp apiServer.js authServer.js

Add a startup command for the new server to the package.json, just after the "apiStart": field :

    "authStart": "nodemon authServer.js",

Add new AUTH_SERVER_PORT env variable to .env file:

AUTH_SERVER_PORT=4000

And use it for the PORT of your new authServer.js:

const PORT = process.env.AUTH_SERVER_PORT

Run both servers from the separate terminals:

npm run apiStart
npm run authStart

πŸ’‘ TIP: You can either open a new terminal or split the existing terminal in VSCode. To do that, focus into your terminal (click inside it) and hit CTRL+SHIFT+` (for new terminal) or CTRL+SHIFT+5 (for split terminal). I prefer the split option, because I can see what's going on in both terminals simultaneously.

Split Terminal

Now that both servers are running, in requests.rest alter the POST/login request to use port 4000 (i.e. different server), copy the accessToken and paste it in the original GET/posts on the port 3000:

#######################################
GET http://localhost:3000/posts
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSmFuZSIsImlhdCI6MTYzMzYyNTE4NH0.bXl9QVdcclzhvYIoFAKL44ErafUiRlwN0RDQ2bkWhEI

#######################################
POST http://localhost:4000/login
Content-Type: application/json

{
    "username": "Jane",
    "password": "abcd"
}

You should see the same response as in chapter 11 screenshot where you used the same server for both requests.

Key thing here is that both servers share the same ACCESS_TOKEN_SECRET and thus are able to work with the token that was signed by it. This is something that would be hard to do if you used sessions to handle this type of situation, because session is bound to a particular server. But with JWT, the needed information is actually stored within the token itself and once issued, it lives on its own inside the requests themselves.

13 - Cleanup the Server Code

You now have 2 servers that will fulfill 2 different roles. The apiServer.js will serve the data if the token is valid, and the authServer.js will issue the tokens.

Remove the authentication stuff from the apiServer.js:

  • remove the whole POST/login endpoint

And remove the API stuff from the authServer.js:

  • remove the whole GET/posts endpoint
  • remove the posts array

Keep the verifyToken function in both.

14 - Set Expiration for the Access Token

Now, stop for a moment and think about the access token. Each and every access token that is generated by the POST/login endpoint can be used indefinitely. Not very secure, is it? No, it's NOT.

What can you do to stop the token from being used? Well, you can change your ACCESS_TOKEN_SECRET on the server, but it's not really a convenient way to invalidate an existing token. If nothing else, changing the secret will invalidate all tokens that were ever signed by that secret in the past. Maybe that's not what you want to do.

So, how do you make a specific token invalid? There are more ways to directly or indirectly make a token invalid.

One of the most common ways is to set an expiry date for the issued token. In real life, depending on the use-case this is set to a reasonably short time, usually minutes, hours or days. The shorter the expiration time is, the more secure the token becomes. In case the token is stolen, it can be only used for a limited period of time.

Add a utility method

function createAccessToken(user) {
    return jwt.sign(user, process.env.ACCESS_TOKEN_SECRET, { expiresIn: "15s" })
}

and change how the accessToken is generated in the POST/login endpoint in authServer.js:

    const accessToken = createAccessToken(user)

Now, when you login and use the newly generated token, you can access the GET/posts for 15 seconds. After that, you will no longer be able to display the posts. Instead, you'll get 403 Forbidden HTTP status. You would need to login again to get the new access token. You can test it now using request.rest.

πŸ’‘ TIP: Go ahead to the jwt.io and paste the new token there to see that a new claim was added to the payload, "exp": <timestamp>. It stands for Expiration Time and if you compare it to the "iat" you'll see it's 15s greater. It's just another one of standard JWT claims that you can find in JWTs.

15 - Create a Refresh Token

In previous chapter you made sure that the token cannot be used indefinitely.

But what should user do when his access token expires? Well, you can ask the user to login again, but that is not a good user experience and authenticating a user can be a costly operation. Here's where refresh tokens come into play.

Usually, after user logs in, login response contains not just the access token, but also a refresh token. In terms of content, it's just another JWT, like the access token. While access token is short-lived, refresh token is meant to be long-lived. You will actually create a refresh token that will have no expiration and you will handle the expiration (or invalidation) explicitly in the code, for example when user logs out. Whenever an access token expires, you will send a request to refresh your access token by using - you guessed it - refresh token.

To generate a new secret for the refresh token run this in the terminal:

node -p "require('crypto').randomBytes(64).toString('hex')"

Create REFRESH_TOKEN_SECRET environment variable in the .env file with the generated value, e.g.:

REFRESH_TOKEN_SECRET=d734cf2b229686c7f0314fb680559c7822b381ae7f357cbf46967afd3fe2151677f907da3bb2acd7603939e989cdda24438def585436dd2cdb958680766d7966

In order to keep track of the refresh tokens and to have an ability to invalidate them, you need to store them somewhere. Usually it's done in a fast database like Redis. In this tutorial you will just use an array of refresh tokens inside the authServer.js code. Declare an empty array of refresh tokens before the POST/login endpoint:

let refreshTokens = []

Change the POST/login endpoint to also create a refresh token. Remember, refresh token has no expiration. Add this under the creation of the access token:

    const refreshToken = jwt.sign(user, process.env.REFRESH_TOKEN_SECRET)

Store the new refresh token in the array you created and add it to the existing response.

    refreshTokens.push(refreshToken)
    res.json({ accessToken: accessToken, refreshToken: refreshToken })

Finally, test your changes by sending a POST/login request to the server. You will get a response containing access AND refresh token:

Response

16 - Getting a New Access Token Based on a Refresh Token

Let's adjust the existing verifyToken function in authServer.js to work with the refresh tokens. It will be similar to the verifyToken function that is used in GET/posts endpoint in apiServer.js with 2 small differences:

  • if the Authorization header contains seemingly valid token it will next check refresh token "storage" that you created in the previous chapter for an existence of the incoming refresh token to confirm that such token was indeed issued previously and return 403 Forbidden if it wasn't
        if (!refreshTokens.includes(token)) return res.sendStatus(403)
  • since it's not an access token now but refresh token, you must use the correct secret to verify the signature in jwt.verify() function:
        jwt.verify(token, process.env.REFRESH_TOKEN_SECRET, (err, payload) => {

Create a new GET/accessToken endpoint in authServer.js that will verify the refresh token in the request (using the adjusted verifyToken route handler) and return a new access token valid for another 15s:

app.get("/accessToken", verifyToken, (req, res) => {
    const user = req.user
    const accessToken = createAccessToken(user)
    res.json({ accessToken: accessToken })
})

Now, when user's access token expires, he's able to request a new one by calling the GET/accessToken given that a valid refresh token is provided in the header. All this without being required to login again.

Add the new request to requests.rest to try it out:

#######################################
GET http://localhost:4000/accessToken
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSmFuZSIsImlhdCI6MTYzNDUwOTUxNX0.pM4SAXRNy4QcCdFgmtr5xmSKazkF-V-RXEJKT2nu5us

17 - Add a Logout Endpoint

As long as the server has the refresh token in its storage, user is able to use it to get the new access token. If you login, you get a refresh token which is stored in the refreshTokens array and as long as it is there, you can use it indefinitely to get a new valid access token.

Let's create a DELETE/logout endpoint in authServer.js that will remove the supplied refresh token from the server's storage so that nobody will be able to use it anymore to get an access token.

πŸ’‘ TIP: Even though it can be virtually any HTTP method, make it a DELETE because you will be deleting something from the server.

app.delete("/logout", (req, res) => {
    const refreshToken = req.body.refreshToken
    refreshTokens = refreshTokens.filter(token => token !== refreshToken)
    res.sendStatus(204)
})

To test the endpoint, create a new request in requests.rest:

#######################################
DELETE http://localhost:4000/logout
Content-Type: application/json

{
    "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSmFuZSIsImlhdCI6MTYzNTAwNjI1NX0.1ktdYG8iENKlrbMs5iSmiUkbsUd-9lkCmuqnYA3mthc"
}

If everything is correct, you will receive 204 No Content HTTP status in the response. The server will remove the token it got in the request body from the refreshTokens array and this refresh token will no longer work in the GET/accessToken endpoint.

You might be asking yourself, what about the access token? Nothing was done about it in the DELETE/logout endpoint. Well yes, it will still work but that's why you set a short expiration in it.

Logout

πŸ’‘ TIP: Read this blog post to get some ideas on how to implement logout functionality with JWTs.

It is said that using JWT should be stateless, meaning that you should store everything you need in the payload and skip performing a DB query on every request. But if you plan to have a strict log out functionality, that cannot wait for the token auto-expiration, even though you have cleaned the token from the client side, then you might need to neglect the stateless logic and do some queries.

An implementation would probably be to store a so-called "blacklist" of all the tokens that should be valid no more but have not expired yet. You can use a DB that has TTL option on documents which would be set to the amount of time left until the token is expired. Redis is a good option for this, that will allow fast in memory access to the list. Then, in a middleware that runs on every authorized request, you should check if provided token is in The Blacklist. If it is, you should throw an unauthorized error. And if it is not, let it go and the JWT verification will handle it and identify whether it is expired or still active.

In short, you should follow these 4 points:

  1. Set a reasonable expiration time on tokens
  2. Delete the stored token from client side upon log out
  3. Have DB of no longer active tokens that still have some time to live
  4. Query provided token against The Blacklist on every authorized request

Summary

That's it! Now you should have a fully functional code that consists of two servers. You should be able to login, display posts with the issued access token, refresh that token when it expires and invalidate the refresh token when you logout.

About

Simple example on how to work with JWT using Node.js and express. IDE used is VSCode running in WSL with Ubuntu distro

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published