DEV Community

Yos Riady
Yos Riady

Posted on • Edited on

Stateless Authentication with JSON Web Tokens

Find more interesting articles at yos.io

Stateless Authentication with JSON Web Tokens

Whether you're writing a public API or an internal microservice, getting authentication right can make or break your API. Let's take a look at a JSON Web Token-based authentication system.

We'll begin with basic authentication & JWT concepts, followed by a detailed walkthrough of designing an authentication service with plenty of code examples.

Before we begin, some definitions are in order:

  • Credential: a fact that describes an identity
  • Authentication: Validation of a credential to identify an entity
  • Authorization: Verification that an entity is allowed to access a resource or perform an action

What are JSON Web Tokens?

JSON Web Tokens (JWT - pronounced "jot") are a compact and self-contained way for securely transmitting information and represent claims between parties as a JSON object.

This is an encoded JSON Web Token:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiI1MWQ4NGFjMS1kYjMxLTRjM2ItOTQwOS1lNjMwZWJiYjgzZGYiLCJ1c2VybmFtZSI6Imh1bnRlcjIiLCJzY29wZXMiOlsicmVwbzpyZWFkIiwiZ2lzdDp3cml0ZSJdLCJpc3MiOiIxNDUyMzQzMzcyIiwiZXhwIjoiMTQ1MjM0OTM3MiJ9.cS5KkPxtEJ9eonvsGvJBZFIamDnJA7gSz3HZBWv6S1Q
Enter fullscreen mode Exit fullscreen mode

JSON Web Tokens such as the one shown is a string consisting of three components, each component delimited by a . (period) character.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJqdGkiOiI1MWQ4NGFjMS1kYjMxLTRjM2ItOTQwOS1lNjMwZWJiYjgzZGYiLCJ1c2VybmFtZSI6Imh1bnRlcjIiLCJzY29wZXMiOlsicmVwbzpyZWFkIiwiZ2lzdDp3cml0ZSJdLCJpc3MiOiIxNDUyMzQzMzcyIiwiZXhwIjoiMTQ1MjM0OTM3MiJ9
.
cS5KkPxtEJ9eonvsGvJBZFIamDnJA7gSz3HZBWv6S1Q
Enter fullscreen mode Exit fullscreen mode

Base64Url decoding a JSON Web Token gives us the following:

{
  "alg": "HS256",
  "typ": "JWT"
}
.
{
  "jti": "51d84ac1-db31-4c3b-9409-e630ebbb83df",
  "username": "hunter2",
  "scopes": ["repo:read", "gist:write"],
  "iss": "1452343372",
  "exp": "1452349372"
}
.
HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret
)
Enter fullscreen mode Exit fullscreen mode

JSON Web Tokens consists of the following three components: the Header, Payload, and Signature. A token is constructed as follows:

  1. You generate a claim of arbitrary JSON data (the Payload), which in our case contains all the required information about a user for the purposes of authentication. A Header typically defines the signing algorithm alg and type of token typ.

  2. You decorate it with some metadata, such as when the claim expires, who the audience is, etc. These are known as claims, defined in the JWT IETF Draft.

  3. The data (both Header and Payload) is then cryptographically signed with a Hash-based Message Authentication Code (HMAC) secret. This Signature is used to verify that the sender of the JWT is who it says it is and to ensure that the message was't changed in the way.

  4. The Header, Payload, and Signature are then Base64 encoded and concatenated together with periods to delimit the fields, which results in the token we see in the first example.

JWTs can also be signed using a secret (with HMAC algorithm) OR a public/private key pair using RSA.

For authentication purposes, a JWT serves as the credential/identity object that clients must show to gatekeepers to verify that you're allowed access protected resources you want to access. It can be signed by a trusted party, and verified by gatekeepers.

JSON Web Tokens work across different programming languages. You should be able to find clients you can use to sign and verify tokens written for your stack.

Authentication Flow

One of the primary use cases of using JWTs is to authenticate requests. Once a user is logged in, each subsequent request can include the JWT to access previously inaccessible protected resources and services.

To illustrate, let's imagine an authentication layer for a set of microservices containing a user's protected resource.

Our authentication flow happens between the following parties:

  • Resource Owner (the User): the party that owns the resource to be shared. Let's call our user Tom.
  • Resource Server: the service that holds the protected resource. Our WalletService holds the Wallet resource, which is a user's digital wallet.
  • Authorization Server: the service that verifies the identity of users. Let's call this AuthService.
  • Client: the application (web/mobile/others) that makes requests to the Resource Server on behalf of the Resource Owner. Let's have a WalletApp Android app.

If you're familiar with OAuth2, our flow is similar to the Resource Owner Password Credentials Grant flow. Depending on your use case, other flows may be a better fit for your application.

Our entire flow goes as follows:

  1. Tom the Resource Owner wants to view the contents of his digital wallet through the Client.
  2. The Client talks to WalletService, requesting for Tom's Wallet resource.
  3. Unfortunately, Wallets are a protected resource. Clients will need to pass an access token to continue.
  4. The Client talks to AuthService, requesting an access token. AuthService responds by asking for the user's credentials.
  5. The Client redirects Tom the Resource Owner to the AuthService, which gives Tom the option to either deny or accept the Client's request for access.
  6. AuthService verifies Tom's credentials, redirects her back to the Client, and grants an Authorization Code to the Client.
  7. The Client presents the authorization code to the AuthService, returning an access token (a JWT) to the Client if successful.
  8. WalletApp presents the access token to the WalletService, requesting for Tom's Wallet resource. Whenever the client wants to access a protected route or resource, it should send the JWT, typically in the Authorization header using the Bearer schema e.g. Authorization: Bearer <token>
  9. WalletService validates the token, decoding the JWT, and parsing its contents.
  10. (Optional, see Revoking Tokens) WalletService asks AuthService to validate the token.
  11. If the access token is valid for the requested operation and resource, WalletService returns Tom's Wallet to the WalletApp Client.
  12. WalletApp shows Tom his Wallet.

Note that the Resource Owner does not share their credentials to the Client directly. Instead, users notify the Authorizer that the Client may access whatever it is that they requested, and the Client authenticates separately with an authorization code. For more details, check out the OpenID Connect spec.

In this article, we're focusing primarily on step 8 to 12.

A Minimum Viable Authentication Service

Let's work on an authentication service for the flow above using plain old Node + Express. Of course, you're free to use whatever you'd like for your own authentication service.

We need at minimum a single endpoint:

HTTP Verb URI Description
POST /sessions Login
// Authentication Service API Login endpoint

var _ = require('underscore');
var Promise = require('bluebird');
var express = require('express');
var router = express.Router();

var models = require('../models');
var User = models.User;
var JWT = require('../utils/jwt');

// Login
router.post('/sessions', function(req, res, next) {
  var params = _.pick(req.body, 'username', 'password', 'deviceId');
  if (!params.username || !params.password || !params.deviceId) {
    return res.status(400).send({error: 'username, password, and deviceId ' +
                                'are required parameters'});
  }

  var user = User.findOne({where: {username: params.username}});
  var passwordMatch = user.then(function(userResult) {
    if (_.isNull(userResult)) {
      return res.status(404).send({error: 'User does not exist'});
    }
    return userResult.comparePassword(params.password);
  });

  Promise.join(user, passwordMatch, function(userResult, passwordMatchResult) {
    if (!passwordMatchResult) {
      return res.status(403).send({
        error: 'Incorrect password'
      });
    }

      var userKey = uuid.v4();
      var issuedAt = new Date().getTime();
      var expiresAt = issuedAt + (EXPIRATION_TIME * 1000);

      var token = JWT.generate(user.username, params.deviceId, userKey, issuedAt, expiresAt);

      return res.status(200).send({
            accessToken: token;
      });
  })
    .catch(function(error) {
      console.log(error);
      next(error);
    });
});
Enter fullscreen mode Exit fullscreen mode
//lib/utils/jwt.js

var _ = require('underscore');
var config = require('nconf');
var jsrsasign = require('jsrsasign');

var sessionKey = require('../utils/sessionKey');
var JWT_ENCODING_ALGORITHM = config.get('jwt:algorithm');
var JWT_SECRET_SEPARATOR = config.get('jwt:secret_separator');

function JWT() {
  this.secretKey = config.get('jwt:secret');
}

// Generate a new JWT
JWT.prototype.generate = function(user, deviceId, userKey, issuedAt,
                                  expiresAt) {
  if (!user.id || !user.username) {
    throw new Error('user.id and user.username are required parameters');
  }

  var header = {
    alg: JWT_ENCODING_ALGORITHM, typ: 'JWT'
  };
  var payload = {
    username: user.username,
    deviceId: deviceId,
    jti: sessionKey(user.id, deviceId, issuedAt),
    iat: issuedAt,
    exp: expiresAt
  };
  var secret = this.secret(userKey);
  var token = jsrsasign.jws.JWS.sign(JWT_ENCODING_ALGORITHM,
                         JSON.stringify(header),
                         JSON.stringify(payload),
                         secret);
  return token;
};

// Token Secret generation
JWT.prototype.secret = function(userKey) {
  return this.secretKey + JWT_SECRET_SEPARATOR + userKey;
};

module.exports = new JWT();

Enter fullscreen mode Exit fullscreen mode

Great! We can now return access tokens upon succesful login. In the next sections, we'll take a look at introducing additional capabilities for our authentication system as well as writing an authentication middleware that we can easily use to protect the routes of future microservices.

But first, let's learn more about the reasons why we use JWTs instead of regular plaintext tokens.

Benefits of Using JWTs for Authentication

Using a JSON Web Token as your identity object gives you a handful of advantages compared to an opaque OAuth2 Bearer token:

1. Fine Grained Access Control: You can specify detailed access control information within the token itself as part of its payload. In the same way that you can create AWS security policies with very specific permissions, you can limit the token to only give read/write access to a single resource. In contrast, API Keys tend to have a coarse all-or-nothing access.

You can populate your tokens with private claims containing a dynamic set of scopes with JWTs. For example:

{
  "jti": "51d84ac1-db31-4c3b-9409-e630ebbb83df",
  "username": "hunter2",
  "scopes": ["repo:read", "gist:write"],
  "iss": "1452343372",
  "exp": "1452349372"
}
Enter fullscreen mode Exit fullscreen mode

Your authentication middleware can parse this JWT metadata and perform validation without making a request to the authorization server. The API endpoint would simply check for the presence of the right scope atribute as follows.

We've covered this in the previous section, alongside code examples.

2. Introspectable: A JSON Web Token carries a header-like metadata that can be easily inspected for client-side validation purposes, unlike plaintext Bearer OAuth tokens which we can't decode and inspect without making calls to our database.

3. Expirable: JSON Web Tokens can have built-in expiry mechanisms through the exp property. The exp (expiration time) claim identifies the expiration time on or after which the JWT MUST NOT be accepted for processing.

4. Stateless: All the information needed to complete a particular request is sent along with it, including an Authorization HTTP header which contains our JWT which serves as an 'identity object.' Since the payload contains all the required information for us to authenticate the user, we can avoid making repeated calls to our database.

5. Encrypted: While a JWT's signature prevents malicious parties from tampering with it, the token's header is only Base64 encoded. When dealing with confidential identifiers in your tokens, you should encrypt your tokens using AES.

At this point you might be thinking:

"Wow, that's great! I can implement my authentication entirely stateless without having to store any session information!"

The above is true in that you can perform client side validation on the exp expiry time claim to invalidate an expired token.

However, we notice a few issues that our current design have not addressed:

What happens when you want to log a user out of an application? Say you updated the schema of your tokens. How do you handle the old tokens that still haven't expired? When you deploy this update to the application, how do you invalidate the current sessions? When you’re storing sessions?

At this point, we don't have a way for our authorization server to invalidate a session that has not yet expired.

Revoking Tokens

One issue with a purely stateless approach is we have no way to revoke/invalidate issued tokens before they expire. In other words, we can't manually log out a user. If a malicious party manages to acquire a token and we KNOW that a malicious party has the token, we'd be sitting ducks. We have no way to take away access for already issued tokens.

We could have client-side logic that clears any expired session tokens during validation. However, client-side security is insufficient. To help prevent token misuse, we need the ability to revoke tokens that have already been issued.

There's the chance that malicious parties can see your services' request headers. Tou should use TLS for client-server and intra-service communication to ensure that nobody sniffs your network requests. Having said that, we want our authentication system to be safe even if an attacker can intercept the network communication between the client and the server.

Depending on your use case, there are two approaches we can take to support two different token invalidation capabilities. Both approaches require the use of additional storage such as Redis for storing some form of a token's identifier.

Redis is a wonderful key-value storage and can be extremely useful to save ephemeral data such as our tokens. It includes features like automatic deletion or expiration for tokens, can handle lots of writes, and is horizontally scalable.

Both approaches also require our validation middleware to make requests to the authorization server for token verification. Let's take a look at how we can implement them:

1. To be able to revoke all tokens belonging to a single user, we can simply sign JWTs belonging to that user with her very own private secret. You can dynamically generate these secrets or you can use a hash of their password.

Then, during our token validation process, we can retrieve this private secret from a DB/service (in our case from KeyService) to verify the token's signature.

Revoking the token can be done by changing or deleting that user's secret, thus invalidating all issued tokens belonging that that user.

2. To be able to revoke an individual token, where users can have multiple tokens on different devices, will require us to generate a unique jti identifier for each JWT, which we can use as an identifier in KeyService for retrieving a dynamically generated, session-specific secret created for the purposes of signing and verifying a single token.

  // Verify JWT
  KeyService.get(payload.jti)
    .then(function(userKey) {
      var authenticated = JWT.verify(token, userKey);
      if (authenticated) {
        return next();
      }

      return next(new Error('403 Invalid Access Token'));
    });
Enter fullscreen mode Exit fullscreen mode

The identifier value MUST be assigned in a manner that ensures that there is a negligible probability that the same value will be accidentally assigned to a different data object; if the application uses multiple issuers, collisions MUST be prevented among values produced by different issuers as well. One approach that can help minimize collisions is to use uuids instead of integers as your identifier.

For both approaches, we also need to prevent replay attacks by including unique identifiers in the jti and timestamps in the iat (issued at) claim as part of your JWT payload. This ensures that each token generated is unique.

We need to add additional endpoints:

HTTP Verb URI Description
POST /sessions Login
GET /sessions/:id Retrieve private secret specific to user/session
DELETE /sessions/:id Logout

The GET endpoint will be primarily used by our authentication middleware to retrieve the secret used to sign the JWT and verify if the signature is valid.

The DELETE endpoint will either change or remove the secret used for the user's session at a particular device so that the JWT signature verification fails and a 403 Forbidden response is triggered.

We also create a service wrapper for storing user/session-specific secrets used to sign JWTs, with methods get, set, and delete:

// KeyService.js, a key storage backed by Redis

// KeyService stores and manages user-specific keys used to sign JWTs
var redis = require('redis');
var Promise = require('bluebird');
var config = require('nconf');
var uuid = require('node-uuid');

var JWT = require('../utils/jwt');
var EXPIRATION_TIME = config.get('key_service:expires_seconds');
var sessionKey = require('../utils/sessionKey');
Promise.promisifyAll(redis.RedisClient.prototype);

function KeyService() {
  this.client = redis.createClient(config.get('key_service:port'),
                                   config.get('key_service:host'));
  this.client.on('connect', function() {
    console.log('Redis connected.');
  });
  console.log('Connecting to Redis...');
}

// Retrieve a JWT user key
KeyService.prototype.get = function(sessionKey) {
  return this.client.getAsync(sessionKey);
};

// Generate and store a new JWT user key
KeyService.prototype.set = function(user, deviceId) {
  var userKey = uuid.v4();
  var issuedAt = new Date().getTime();
  var expiresAt = issuedAt + (EXPIRATION_TIME * 1000);

  var token = JWT.generate(user, deviceId, userKey, issuedAt, expiresAt);
  var key = sessionKey(user.id, deviceId, issuedAt);

  var setKey = this.client.setAsync(key, userKey);
  var setExpiration = setKey.then(this.client.expireAsync(key,
                                  EXPIRATION_TIME));
  var getToken = setExpiration.then(function() {
    return token;
  });

  return getToken;
};

// Manually remove a JWT user key
KeyService.prototype.delete = function(sessionKey) {
  return this.client.delAsync(sessionKey);
};

module.exports = new KeyService();

Enter fullscreen mode Exit fullscreen mode

Note that an expiry mechanism is built in, which utilizes Redis' EXPIRE feature to automatically remove sessions that have expired, thereby invalidating any issued tokens signed with that secret.

Here is our main router, updated to handle the additional endpoints and talk to KeyService:

// Authentication Service API endpoints

var _ = require('underscore');
var Promise = require('bluebird');
var express = require('express');
var router = express.Router();

var models = require('../models');
var User = models.User;
var KeyService = require('../services/KeyService');
var ErrorMessage = require('../utils/error');

// Register
router.post('/users', function(req, res, next) {
  var params = _.pick(req.body, 'username', 'password');
  if (!params.username || !params.password) {
    return res.status(400).send({error: 'username and password ' +
                                'are required parameters'});
  }

  User.findOrCreate({
    where: {username: params.username},
    defaults: {password: params.password}
  })
  .spread(function(user, created) {
    if (!created) {
      return res.status(409).send({error: 'User with that username ' +
                                  'already exists.'});
    }
    res.status(201).send(user);
  })
  .catch(function(error) {
    res.status(400).send(ErrorMessage(error));
  });
});

// Login
router.post('/sessions', function(req, res, next) {
  var params = _.pick(req.body, 'username', 'password', 'deviceId');
  if (!params.username || !params.password || !params.deviceId) {
    return res.status(400).send({error: 'username, password, and deviceId ' +
                                'are required parameters'});
  }

  var user = User.findOne({where: {username: params.username}});
  var passwordMatch = user.then(function(userResult) {
    if (_.isNull(userResult)) {
      return res.status(404).send({error: 'User does not exist'});
    }
    return userResult.comparePassword(params.password);
  });

  Promise.join(user, passwordMatch, function(userResult, passwordMatchResult) {
    if (!passwordMatchResult) {
      return res.status(403).send({
        error: 'Incorrect password'
      });
    }

    return KeyService.set(userResult, params.deviceId)
        .then(function(token) {
          res.status(200).send({
            accessToken: token
          });
        });
  })
    .catch(function(error) {
      console.log(error);
      next(error);
    });
});

// Get Session
router.get('/sessions/:sessionKey', function(req, res, next) {
  var sessionKey = req.params.sessionKey;
  if (!sessionKey) {
    return res.status(400).send({error: 'sessionKey is a required parameters'});
  }

  KeyService.get(sessionKey)
    .then(function(result) {
      if (_.isNull(result)) {
        return res.status(404).send({error: 'Session does not exist or has ' +
                                    'expired. Please sign in to continue.'});
      }
      res.status(200).send({userKey: result});
    })
    .catch(function(error) {
      console.log(error);
      next(error);
    });
});

// Logout
router.delete('/sessions/:sessionKey', function(req, res, next) {
  var sessionKey = req.params.sessionKey;
  if (!sessionKey) {
    return res.status(400).send({error: 'sessionKey is a required parameter'});
  }

  KeyService.delete(sessionKey)
    .then(function(result) {
      if (!result) {
        return res.status(404).send();
      }
      res.status(204).send();
    })
    .catch(function(error) {
      console.log(error);
      next(error);
    });
});

module.exports = router;
Enter fullscreen mode Exit fullscreen mode

Updated Authentication Flow

Below is our updated flow with support for revoking already issued tokens:

We introduce some additional steps in our token validation process (this happens in our middleware) that communicates with an external private secret storage KeyService to retrieve the secrets necessary for decoding and verifying the JWT signature.

As we've talked about, this allows us to introduce the ability to expire and manually revoke already issued tokens at the cost of some complexity.

A Minimum Viable Authentication Middleware

Alongside our AuthService, we can and should write a companion Node.js module that other developers can use to easily add authentication to their microservices. For example:

var auth = require('auth');
router.post('/protected', auth.isAuthenticated, function(req, res, next) {
  res.status(200).send();
});
Enter fullscreen mode Exit fullscreen mode

FYI, the isAuthenticated middleware checks that the Authorization: Bearer headers in each incoming request to that route is valid. The source code is supplied below.

You can also protect ALL routes like so:

var auth = require('auth');
app.use(auth.isAuthenticated);
Enter fullscreen mode Exit fullscreen mode

The isAuthenticated middleware can be written as follows:

// index.js

var base64url = require('base64url');
var JWT = require('./lib/utils/jwt');
var KeyService = require('./lib/services/KeyService');

function isAuthenticated(req, res, next) {
  // Guard clauses
  var authorization = req.headers.authorization;
  if (!authorization || !(authorization.search('Bearer ') === 0)) {
    return next(new Error('401 Missing Authorization Header'));
  }
  var token = authorization.split(' ')[1];
  if (!token) {
    return next(new Error('401 Missing Bearer Token'));
  }

  // Unpack JWT
  var components = token.split('.');
  var header = JSON.parse(base64url.decode(components[0]));
  var payload = JSON.parse(base64url.decode(components[1]));
  var signature = components[2];

  // Verify JWT
  KeyService.get(payload.jti)
    .then(function(userKey) {
      var authenticated = JWT.verify(token, userKey);
      if (authenticated) {
        return next();
      }

      return next(new Error('403 Invalid Access Token'));
    });
}

module.exports = {
  isAuthenticated: isAuthenticated
};

Enter fullscreen mode Exit fullscreen mode

KeyService is a wrapper over the Redis storage used to store session-specific user keys, indexed by a uuid identifier contained in the token's jti claim. We've seen this before, except the operations defined for our middleware is strictly read-only.

// KeyService stores and manages user-specific keys used to sign JWTs
var redis = require('redis');
var Promise = require('bluebird');
var config = require('nconf');

Promise.promisifyAll(redis.RedisClient.prototype);

function KeyService() {
  this.client = redis.createClient(config.get('key_service:port'),
                                   config.get('key_service:host'));
  this.client.on('connect', function() {
    console.log('Redis connected.');
  });
  console.log('Connecting to Redis...');
}

// Retrieve a JWT user key
KeyService.prototype.get = function(sessionKey) {
  return this.client.getAsync(sessionKey);
};

module.exports = new KeyService();
Enter fullscreen mode Exit fullscreen mode

JWT is a lightweight wrapper of the jsrsasign crypto library. We use the jsrsassign crypto library to verify our JWTs:

Be careful when choosing which crypto library you use to sign and verify JWTs. Be sure to check jwt.io for any security vulnerabilities.

// lib/utils/jwt.js

var _ = require('underscore');
var config = require('nconf');
var jsrsasign = require('jsrsasign');
var base64url = require('base64url');

var JWT_ENCODING_ALGORITHM = config.get('jwt:algorithm');
var JWT_SECRET_SEPARATOR = config.get('jwt:secret_separator');

function JWT() {
  this.secretKey = config.get('jwt:secret');
}

JWT.prototype.verify = function(token, userKey) {
  var secret = this.secret(userKey);
  var isValid = jsrsasign.jws.JWS.verifyJWT(token,
                                            secret,
                                            {
                                              alg: [JWT_ENCODING_ALGORITHM],
                                              verifyAt: new Date().getTime()});
  return isValid;
};

JWT.prototype.secret = function(userKey) {
  return this.secretKey + JWT_SECRET_SEPARATOR + userKey;
};

module.exports = new JWT();
Enter fullscreen mode Exit fullscreen mode

Q: Why are we using both a global secret alongside the user-specific secret when computing secret?

A: Having a global secret lets us easily invalidate ALL tokens belonging to ALL users by simply changing this single secret value, thus invaliding the JWT signatures of all already issued tokens.

Writing modules for cross-cutting concerns such as authentication in this way lets you save development time and effort on future microservices. You can quickly bootstrap new services with an increasingly rich set of capabilities as you write more and more reusable modules. Shared modules also helps keep behaviour consistent across all your different services.

Other JWT Use Cases

JSON Web Tokens can securely transmit information between parties, since its signature lets us be sure that its senders are who we expect. Other use cases involving JWTs include as tokens within reset password links. We can use JWTs to create signed hyperlinks without needing to store password reset tokens in a database.

In Closing

I've presented one approach to building an authentication layer using JSON Web tokens. We've also gone through a number of the design decisions to help prevent some security loopholes.

While JWTs may seem like a fairly sane method of authentication it's important for us to not ignore the lessons we've learned from older authentication schemes with years of combat experience.

Through this process, I hope I've shared with you how client-side authentication schemes using JWTs has its own risks and limitations which needs to be investigated throughly before going into implementation.

Let me know what you think in the comments below!

Additional Reading

Find more interesting articles at yos.io

Top comments (3)

Collapse
 
veddingindia profile image
Awesome

Glad I found this article and JWT. I also recommends a jsonformatter.org/json-parser who is working on JWT. It can validate the JWT session.

Collapse
 
devlist profile image
Devlist

Love to suggest a json validator which help you validate and format JSON data. jsonhome.com

Collapse
 
ddziaduch profile image
Damian Dziaduch

Hello Yos, thanks for great article! :)