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
- Visual Studio Code on WSL
- VSCode extension Remote - WSL
- VSCode extension REST Client - you can use any other REST client you are comfortable with, e.g. Postman. You will use it to test the calls to the API endpoints we're going to create
- nvm, node.js and npm on WSL (the instructions are perfectly OK for WSL 1, too) - install the recommended LTS version of the node.js
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
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:
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 tojwt-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.
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.
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.
npm install express jsonwebtoken dotenv
express
for our server code and API endpointsjsonwebtoken
for working with JWTdotenv
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 callednode_modules
were created.
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
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.
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
.
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:
Now, you can click on Send Request
and the resulting response will open in the new split window:
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.
- Create a POST
/login
endpoint inapiServer.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 ofuser
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.jscrypto
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 π):Each time you run that, a new random string is generated. Createnode -p "require('crypto').randomBytes(64).toString('hex')" # -p prints out the evaluated input
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 wherenodemon
is running, or simply by exiting and starting the server again.
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:
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.
- in
apiServer.js
create a middleware function (or route handler) calledverifyToken
function verifyToken(req, res, next) { }
- add it to the GET
/posts
endpoint to the chain of our route handlersapp.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 thespace
character) and take the second array element from itconst 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 statusif (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 sequencejwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, obj) => { if (err) return res.sendStatus(403) next() })
Just send GET/posts
request from the requests.rest
file as is.
You should see Unauthorized in the 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.
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 thejsonwebtoken
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?
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.
π‘ TIP: Replace the token in GET
/posts
request with John's token and see what posts are returned.
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.
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.
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.
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.
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:
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'tif (!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
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.
π‘ 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:
- Set a reasonable expiration time on tokens
- Delete the stored token from client side upon log out
- Have DB of no longer active tokens that still have some time to live
- Query provided token against The Blacklist on every authorized request
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.