This is one of the short articles that should help you quickly set up basic form of authentication with JWT. I’m guessing that you already know what JWT is. Writing custom authentication flow can be a pain in the butt, but JWT makes a bit easier by introducing a secure communication channel between browser and server using access and refresh tokens.
Although JWT is a nice platform, you should never rely just on JWT when it comes to authentication and/or authorization. In this tutorial, I will be covering a very simple case where we generate access and refresh tokens for the user and return them to the browser as a httpOnly cookie. (I’m assuming that you already have your basic authentication with database set up) We will store the data in Redis which is very easy to install if you have docker on your machine. Let’s get right into it.
1. Install Redis using Docker
Redis is an in-memory (can be also persisted) key/value store, which we will use for storing user tokens. The easiest way to install Redis is using a Docker installation. By using Docker, you don’t interfere with your operating system at all. Instead, your Redis keystore will run in a separate container which will be only used by your web app.
To install Docker, run:
sudo apt install apt-transport-https ca-certificates curl software-properties-common curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add - sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu bionic stable" sudo apt update sudo apt install docker-ce
Then, you can use Docker to fire up your Redis container.
docker run --name myrediskeystore -d redis:latest
Check the status of your container and if it isn’t running, start it up.
vlm@vlm:~$ docker ps -a CONTAINER ID STATUS PORTS NAMES ea44f1638357 Up 9 seconds 6379/tcp myrediskeystore # if your container isn't up, run "docker start <container id>: vlm@vlm:~$ docker start ea44f1638357
If you’d like to start your Redis with a persistent storage head over here, or you’d like to know more about the docker image, go here.
2. Install JWT and Redis dependencies
In your project/web app, run following two lines to install dependencies which we will use for this tutorial.
npm install jsonwebtoken --save npm install redis --save
It’s also a good idea to read documentation, so you have an overview of what we will be doing. Head over to the JWT repo and the Redis repo now.
3. Import dependencies and connect to Redis
Copy and paste this code to your main application file (index.js or so).
const redis = require("redis"); const jwt = require('jsonwebtoken'); var rediscl = redis.createClient(); rediscl.on("connect", function () { console.log("Redis plugged in."); });
As you noticed, I’m not passing any configuration to Redis. createClient() will use default values if no configuration is specified. And because I’m running Redis on localhost, on default port and without basic authentication, I could leave the “constructor” empty. For the purposes of this tutorial it’s okay to have the connection unprotected, but if you ever decide to take it further, you should secure your installation.
4. Define JWT variables
Paste following code below the code from step 3.
const jwt_secret = "jwtfanhere"; const jwt_expiration = 60 * 10; const jwt_refresh_expiration = 60 * 60 * 24 * 30;
- jwt_secret is a keyword or sentence that will be used on your server to encrypt the payload
- jwt_expiration is time during which the access token will be valid
- jwt_refresh_expiration is time during which the refresh token will be valid
Usually, refresh tokens can stay the same for a longer period of time, maybe even a year or two (wow, that was optimistic). Access tokens are rotated all the time, in short periods of time, because if someone hacks you and is now in possession of your access token, you probably don’t want him to hang around on your web app profile for too long. In fact, you want it to be as short as possible. That’s why we set the access token expiration to 10 minutes.
5. Define application routes
In this tutorial, I’m using Express as an application server, but you can go ahead and use any other framework. Be careful though, cookies might be located in different part of req/res if you use other framework, so some refactoring might be needed.
There will be four routes in our small web app.
app.post("/register", (req, res, next) => { // When user registers, you don't really do anything about it // in your JWT logic. You will first give them tokens when // they log in. In this part of the code, you store // the user somewhere into database and maybe send verification // link on email } app.post("/login", (req, res, next) => { // Loging the user in - in this part, we will generate a new // access-refresh token pair and return it to the user as part // of the response object, in httpOnly cookies. We will also save // this pair in Redis. } app.post("/logout", (req, res, next) => { // Logging the user out - we remove user's tokens from Redis // as well as from the httpOnly cookies } app.post("/profile", (req, res, next) => { // Here we can check if cookies are present and valid. // Then we use JWT payload to determine user's ID. }
6. Generate or delete user tokens on login/logout
This part is easy – it simply deals with issuing new tokens on user login, or removal of tokens on user logout.
As soon as the user has tokens, we can start validating them. This will be the next step; for now, use the code below in your application routes.
app.post("/login", (req, res, next) => { // When user logs in, there is no token pair in the browser // cookies. We need to issue both of them. Because you also // log user in in this step, I assume that you already have // their user ID. let user_id = 2212; // Generate new refresh token and it's expiration let refresh_token = generate_refresh_token(64); let refresh_token_maxage = new Date() + jwt_refresh_expiration; // Generate new access token let token = jwt.sign({ uid: user_id }, jwt_secret, { expiresIn: jwt_expiration }); // Set browser httpOnly cookies res.cookie("access_token", token, { // secure: true, httpOnly: true }); res.cookie("refresh_token", refresh_token, { // secure: true, httpOnly: true }); // And store the user in Redis under key 2212 redis.set(user_id, JSON.stringify({ refresh_token: refresh_token, expires: refresh_token_maxage }), redis.print ); } app.post("/logout", (req, res, next) => { // Delete user refresh token from Redis redis.del(req.body.uid); // ... and then remove httpOnly cookies from browser res.clearCookie("access_token"); res.clearCookie("refresh_token"); res.redirect("/"); }
7. Verify user when accessing sensitive routes
This is the most important – and most difficult – part of the process. So right now, users either have or have not the token pair. If they do, it means that they logged in successfully and are a valid user of your web app. If they don’t, they probably registered, but never logged in (or manually deleted these tokens from their browser). And if they do have them but tokens are invalid, they either expired, or someone is trying to hack your web app (with invalid tokens).
Whenever someone accesses sensitive data on your web, or tries to trigger a function which inserts/updates/deletes data to/from database, you need to make sure that the user is valid. The following function does just that.
But there is still one scenario which you should try to prevent yourself. If you get hacked and someone gets your tokens, the hacker can now try to access every part of your web app. In my tutorial, we take care of authorization (authorization, not authentication) inside of JWT payload body, which is only secure on basic level. Try to think about ways to improve authorization in my code and let me know in the comment section if you think of something 🙂
// Let's define a helper function that we will use in most of our routes. // If you can't see the code properly, click on "Open Code in new Window" function validate_jwt(req, res) { // Let's make this Promise-based return new Promise((resolve, reject) => { let accesstoken = req.cookies.access_token || null; let refreshtoken = req.cookies.refresh_token || null; // Check if tokens found in cookies if (accesstoken && refreshtoken) { // They are, so let's verify the access token jwt.verify(accesstoken, jwt_secret, async function(err, decoded) { if (err) { // There are three types of errors, but we actually only care // about this one, because it says that the access token // expired and we need to issue a new one using refresh token if (err.name === "TokenExpiredError") { // Let's see if we can find token in Redis. We should, because // token expired, which means that we already inserted it into // redis at least once. let redis_token = rediscl.get(decoded.uid, function(err, val) { return err ? null : val ? val : null; }); // If the token wasn't found, or the browser has sent us a refresh // token that was different than the one in DB last time, then ... if ( !redis_token || redis_token.refresh_token === refreshtoken ) { // ... we are probably dealing with hack attempt, because either // there is no refresh token with that value, or the refresh token // from request and storage do not equal for that specific user reject("Nice try ;-)"); } else { // It can also happen that the refresh token expires; in that case // we need to issue both tokens at the same time if (redis_token.expires > new Date()) { // refresh token expired, we issue refresh token as well let refresh_token = generate_refresh_token(64); // Then we assign this token into httpOnly cookie using response // object. I disabled the secure option - if you're running on // localhost, keep it disabled, otherwise uncomment it if your // web app uses HTTPS protocol res.cookie("__refresh_token", refresh_token, { // secure: true, httpOnly: true }); // Then we refresh the expiration for refresh token. 1 month from now let refresh_token_maxage = new Date() + jwt_refresh_expiration; // And then we save it in Redis rediscl.set( decoded.uid, JSON.stringify({ refresh_token: refresh_token, expires: refresh_token_maxage }), rediscl.print ); } // Then we issue access token. Notice that we save user ID // inside the JWT payload let token = jwt.sign({ uid: decoded.uid }, jwt_secret, { expiresIn: jwt_expiration }); // Again, let's assign this token into httpOnly cookie. res.cookie("__access_token", token, { // secure: true, httpOnly: true }); // And then return the modified request and response objects, // so we can work with them later resolve({ res: res, req: req }); } } else { // If any error other than "TokenExpiredError" occurs, it means // that either token is invalid, or in wrong format, or ... reject(err); } } else { // There was no error with validation, access token is valid // and none of the tokens expired resolve({ res: res, req: req }); } }); } else { // Well, no tokens. Someone is trying to access // your web app without being logged in. reject("Token missing.") }; }); } // A little helper function for generation of refresh tokens function refresh_token(len) { var text = ""; var charset = "abcdefghijklmnopqrstuvwxyz0123456789"; for (var i = 0; i < len; i++) text += charset.charAt(Math.floor(Math.random() * charset.length)); return text; }
Hopefully you didn’t lose your mind over the amount of code. I tried to explain as much as I could in the code comments. But that’s almost all. Right now we can:
- Push tokens to user browser and to our Redis storage upon login
- Withdraw tokens from user browser and our Redis storage upon logout
- Use the code from above to:
- Verify user by checking if he has tokens
- Verify these tokens based on our server secret
- When tokens expire, we check if user with that specific ID (from JWT payload) sent us the same refresh token as the one in DB. If yes, we generate new token pair. If not, it’s possible that someone tries to mimic user ID in JWT payload but actually has different expired tokens.
There is still space for improvement, although the solution it’s pretty solid! The only thing left for you to do is to make sure that user with the specific ID can only insert/update/delete resources that are his own.
So now we use this code in a following way:
app.post("/profile", (req, res, next) => { // let's say that the unknown user wants to edit some profile validate_jwt(req, res, pgdb).then(result => { // Pass your modified request and result objects further // to any method that generates content, or works with DB, // or whatever you like some_other_method(result.req, result.res); }) .catch(error => { throw error; }); }
You can of course use this code on any route in your web app.
Conclusion
Let me know if this helped you in the comment section below. I had very limited time to write this article, so I will be more than happy to hear from you if you have some feedback. Thanks.