What is a JWT? Link to heading

JWT stands for JSON Web Token and it’s an open standard introduced by RFC 7519. Quoting the standard,

JSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between two parties.
The claims in a JWT are encoded as a JSON object that is used as the payload of a JSON Web Signature (JWS) structure or as the plaintext of a JSON Web Encryption (JWE) structure, enabling the claims to be digitally signed or integrity protected with a Message Authentication Code (MAC) and/or encrypted.

In more practical terms, JWT allows two parties to securely exchange information in a self-contained manner.
Even though it supports encrypted payloads using asymmetric encryption, the beauty of JWT is that it enables unencrypted claims(e.g. authentication tokens, authorization details) to be transferred between parties, without compromising the trust on the integrity of the content.
This is because JWTs are signed using a secret, which allows the signer to verify that the payload is not tampered.

Structure Link to heading

A JWT looks like this:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwibmFtZSI6Ikh1c2V5aW4gWmVuZ2luIiwiaWF0IjoxNzEzNzExNzQ4fQ.8_OFMl_vBkY6f6hTxJMF5BBaeItytJIQi9BkGotpQlg

JWTs consist of three dot-separated parts: Header.Payload.Signature. All three parts are base64 encoded. While both Header and Payload are indeed encoded JSON objects, the Signature is a hash of the concatenated Header and Payload using a secret word.

First part, the header, consists of the algorithm, e.g. HS256, and the type of the token, jwt.

{ "alg": "HS256", "typ": "JWT" }

Base64 encoded as eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.

Payload, as the name suggests, contains whatever information the issuer wants. Our payload, once again, is just a base64 encoded JSON object:

{"sub":"1","name":"Huseyin Zengin","iat":1713711748,"exp":9999999999}`

Note that there are a bunch of “registered” field names to avoid; listed in the RFC. There is also a public registry run by IANA to provide a collision-free experience. Neither of them are imposes any real requirements in the normal usage though.

The real beauty of JWT is thanks to the last part, signature. This part, for signed tokens, is just an hash of rest of the token it self, using a secret word.
It is simply HASH($base64_header.$base64_payload, some_secret_word).

Why JWTs? Link to heading

This is just my subjective experience that led me to use JWTs and write this post.

Imagine you’re developing a simple client-server application with simple authentication for users. In a professional environment you likely already have a authentication and authorization solution in place.
However, if you’re just starting a side-project or just need a proof of concept, you need to decide how you’re going to solve the authentication problem.

Authentication, and Authorization, can get very confusing very quickly when looked in detail.
However, a simple authentication scenario, e.g. username-password login, from a high-level actually looks very simple;

  1. Client presents some identification.
  2. Server verifies the identity and responds with some, often temporary, “information” to be used in subsequent communication.
  3. Client presents this new “information” with each request.
  4. Server verifies the client identity using the “information”.

Usually, the process of a server issuing the “information” involves some kind of stateful operation.

In the classic example of session-based authentication:

  1. Server generates a key-value pair of Session Id to User Information.
    SOME_RANDOM_SESSION_ID -> {"userId": 1, "username": "admin"}
  2. The server stores this information, typically in a persistent, key-value store accessible to each server instance.
  3. For each request, the server needs to fetch the value for the key sent by the client, the session id.

Since JWTs are ‘self-contained,’ meaning the integrity of their payload is verifiable by the issuer, they eliminate the need for using a persistent store. The process now looks something like this:

  1. Server issues a JWT containing the required information in the payload
    {"userId": 1, "username": "admin"}.
  2. For each request, the server parses and verifies the JWT token to get the user information.

Since there is no need to persist any information in the backend, these tokens are valid globally in any instance deployed anywhere in time and space.

A minimal example Link to heading

For the sake of simplicity, we’re going to create an Express.js server, using typescript and the jsonwebtoken package and call it using curl.

Note: By no means the following example is the proper way of doing things, it’s only meant to be a dirty quick demonstration.

Let’s start with the basics:

import express from "express";
import * as jwt from "jsonwebtoken";

const app = express();
const SECRET = "VERY_SECRET_KEY"; // We are going to use this to sign our tokens

app.use(express.json()); // Let's stick to JSON for the entire API

// app.post('/login', (req, res) => {...});
// app.get("/protected", (req, res) => {...});

app.listen(3000);

Let’s implement the login route now:

app.post("/login", (req, res) => {
  // Deconstruct the request body.
  const { username, password } = req.body;

  // Verify username-password.
  if (username != "foo" || password != "bar") {
    res.sendStatus(401);
  } else {
    // Create the payload.
    const payload = { username: "foo", id: 1 };
    // Create/sign it into a token.
    const token = jwt.sign(payload, SECRET);
    // Respond with the token.
    res.json({ success: true, token: token });
  }
});

Code being self-explanatory, let’s call our brand-new endpoint using curl:

curl localhost:3000/login \
 -H "Content-Type: application/json" \
 --data '{"username": "foo", "password": "bar"}'

It responds with:

{
  "success": true,
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImZvbyIsImlkIjoxLCJpYXQiOjE3MTQzMjQyMjh9.xdEf6EdwthfpRs7e21Dqc6y_yopLDD3znnRX_iDNN64"
}

Now let’s look at our token in depth, we can use the wonderful debugger at https://jwt.io/.

Note that we use our secret to verify the signature, and we can see that it’s indeed verified.

Now we need to verify our token to protect a route with authentication.

app.get("/protected", (req, res) => {
  // Extract the token from the header
  const token = req.header("Authorization")?.replace("Bearer ", "");
  // Token is missing
  if (!token) {
    res.sendStatus(401);
  } else {
    // Deconstruct the token
    const { username } = jwt.verify(token, SECRET) as jwt.JwtPayload;
    // Say hello to our user
    res.json({
      message: `Hello ${username}.`,
    });
  }
});

Now let’s call our endpoint with the token we received in the previous step:

curl localhost:3000/protected \
 -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImZvbyIsImlkIjoxLCJpYXQiOjE3MTQzMjQyMjh9.xdEf6EdwthfpRs7e21Dqc6y_yopLDD3znnRX_iDNN64"

And here we are:

{
  "message": "Hello foo."
}

Note: For any use-case that’s even slightly more complicated this proof-of-concept, you better create a middleware for the protecting the routes, or even better, just use Passport.js with JWT strategy.

Problems with using JWT Link to heading

JWTs truly simplify the authentication for a lot of use cases.

However, they come with some limitations and disadvantages, just like every other approach.
It’s certainly possible to mitigate most of these in one way or another. However, possible mitigations are beyond the scope of this post.

1. On-demand Revocation Link to heading

Unlike sessions, where you can simply remove the session ID from the Key-Value store, JWT tokens remain technically valid until their set expiry date.
This makes implementing a feature such as “Logout from all devices” less trivial.

2. Token Size Link to heading

If you decide to represent a large amount of user information in a JWT, they can quickly become large.
This leads to increased network traffic and risks exceeding the maximum header sizes imposed by clients.

3. Lack of updates Link to heading

JWTs do not come with a built-in mechanism to update the token payload.
In certain scenarios, such as authorization scopes being updated, where you need to update the payload, JWTs are not as flexible as sessions, where you can simply update the value corresponding the session id in your persistent storage.

4. Security Link to heading

Since JWTs rely on cryptography for integrity check, the consequences of a compromised secret key are bitter.
A compromised secret key means attackers can forge valid JWTs, pretending to be any user.

Conclusion Link to heading

Despite its disadvantages and shortcomings, I still think that JWTs are an elegant solution whenever you need a simple authentication mechanism.
At the end of the day, I find having a simple authentication mechanism without a persistent data storage (also not affected by server restarts unlike in-memory sessions) with little effort very appealing for any side-project, services running in contained environments and with trivial authentication needs.