JWT Authentication Explained with Code Examples

JWT Authentication Explained with Code Examples

The Wikipedia description of a JSON Web Token (JWT) is:

JSON Web Token is a proposed Internet standard for creating data with optional signature and/or optional encryption whose payload holds JSON that asserts some number of claims.

However, this definition says a lot without really saying a lot. When I’m trying to understand a concept, I like to play around with relevant libraries. We’ll try this out with JWTs using the popular javascript library jsonwebtoken.

Creating a JWT

The first thing the docs mention is that the sign function returns a JWT, and the only required arguments are some JSON and a string called secret.

const jwtLibrary = require('jsonwebtoken');

// The only arguments we need are a secret value and some JSON
const json = {"key": "value", "key2": "value2"}
const secret = "shhhhh";

// Ignore the options for now, we'll check them later
const jwt = jwtLibrary.sign(json, secret);

console.log("JWT:", jwt);
// JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ2YWx1ZSIsImtleTIiOiJ2YWx1ZTIiLCJpYXQiOjE2MzQxNzgxMTB9.vnXM0oxw05QH1Vs6RsvYp6LaEqFFqZ-NExQMXBgP7Mk

This is our first look at a what a JWT looks like.

Using a JWT

What can we do with this JWT? The library has two other methods, verify and decode. It lists verify first so we’ll try that first:

// From previous example
const jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ2YWx1ZSIsImtleTIiOiJ2YWx1ZTIiLCJpYXQiOjE2MzQxNzgxMTB9.vnXM0oxw05QH1Vs6RsvYp6LaEqFFqZ-NExQMXBgP7Mk";
const secret = "shhhhh";

// Ignore the options for now, we'll check them later
const verifyResult = jwtLibrary.verify(jwt, secret);

console.log("verifyResult:", verifyResult);
// verifyResult: { key: 'value', key2: 'value2', iat: 1634178110 }

It looks like we got back the JSON that we specified above plus an extra entry iat. The docs say that iat is short for issued at and is a unix timestamp of when the JWT was created.

What happens if we used the wrong secret?

const jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ2YWx1ZSIsImtleTIiOiJ2YWx1ZTIiLCJpYXQiOjE2MzQxNzgxMTB9.vnXM0oxw05QH1Vs6RsvYp6LaEqFFqZ-NExQMXBgP7Mk";
const incorrectSecret = "thisiswrong";

const verifyResult = jwtLibrary.verify(jwt, incorrectSecret);
// JsonWebTokenError: invalid signature

Unsurprisingly, we get an error. So far, we can determine that a JWT somehow encodes the JSON value that we passed in along with other metadata (iat). Later on, we can check that a JWT was created with a specific secret and get back that encoded JSON.

What about the decode method?

// From previous example
const jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ2YWx1ZSIsImtleTIiOiJ2YWx1ZTIiLCJpYXQiOjE2MzQxNzgxMTB9.vnXM0oxw05QH1Vs6RsvYp6LaEqFFqZ-NExQMXBgP7Mk";

const decodeResult = jwtLibrary.decode(jwt);
console.log("decodeResult:", decodeResult);
// decodeResult: { key: 'value', key2: 'value2', iat: 1634178110 }

This is kind of strange. We didn’t pass in the secret, but we still got back the original JSON and iat. There’s a warning on the method in the docs which gives us a hint about what’s going on:

Warning: This will not verify whether the signature is valid. You should not use this for untrusted messages. You most likely want to use jwt.verify instead.

This tells us something important. The JSON within the JWT is not encrypted. If we store anything sensitive in a JWT, anyone could read it, even if they don’t have the secret.

Where are JWTs used?

A quick recap on what we’ve learned:

  • A JWT can be created with JSON and a secret
  • Anyone can get the JSON out of the JWT, even without the secret
  • We can verify that a JWT was created with a specific secret

One common example is authentication. After a user logs in, we can create a JWT containing metadata about the user, like:

const jwtLibrary = require('jsonwebtoken');
const secret = "shhhhh";

function createJwtForUser(userId) {
    return jwtLibrary.sign({"user_id": userId}, secret);
}

Users can send us the JWT, and we can securely know who sent it.

function getUserIdForJwt(jwt) {
    try {
        return jwtLibrary.verify(jwt, secret)["user_id"];
    } catch(err) {
        // This is not a valid user
        return null;
    }
}

All we need is our secret, and we are confident in the returned user_id. The only way someone could impersonate a user is if they had our secret (so choose something better than shhhhh) or if they stole a valid JWT from someone else (so make sure to keep them safe).

Additionally, we don’t need to maintain any state or query any external services to validate the user_ids.

JWT Signing Options

The sign function takes in a bunch of options that we have skipped. Let’s go back and look at some.

const jwtLibrary = require('jsonwebtoken');

const json = {"whatever we want": "anything"}
const secret = "shhhhh";

// Specify expiresIn for 1h
const jwt = jwtLibrary.sign(json, secret, {expiresIn: '1h'});
const verifyResult = jwtLibrary.verify(jwt, secret);

console.log("verifyResult:", verifyResult)
// verifyResult: { 'whatever we want': 'anything', iat: 1634186608, exp: 1634190208 }

After adding expiresIn, we can see that a new entry was added to the JSON exp. exp is another unix timestamp, and it’s 3600 seconds (1 hour) after the issued time. What happens when the time expires? We can either wait an hour or speed things up by specifying a negative expiresAt.

// ... same as before
const jwt = jwtLibrary.sign(json, secret, {expiresIn: '-1h'});
const verifyResult = jwtLibrary.verify(jwt, secret);
// TokenExpiredError: jwt expired

We get an expected error, because the jwt expired an hour ago. Why is expiresIn useful? We said before that once we create a JWT we can check that it’s valid without doing any external lookups. The issue with this is once a JWT is created, it’s valid forever (as long as the secret doesn’t change).

exp allows us to bound how long the token is valid for, by encoding that information in the JSON itself.

Note that while this library allows us to specify it in a user-friendly way (1h), we could also have just added it directly to the JSON:

const json = {
    "whatever we want": "anything",
    "exp": Math.floor(Date.now() / 1000) - (60 * 60), // 1 hour in the past
}
const secret = "shhhhh";

const jwt = jwtLibrary.sign(json, secret)
const verifyResult = jwtLibrary.verify(jwt, secret);
// TokenExpiredError: jwt expired

This is actually how most of the options work. They are a nice way to specify entries (also known as claims) that are added to the JSON. The issuer option, for example, adds a claim iss to the JSON.

iss is used as an id for whoever created the JWT. The party verifying the JWT can check the iss to make sure it came from the source they were expecting:

const json = {"user_id": "8383"}
const secret = "shhhhh";

const jwt = jwtLibrary.sign(json, secret, {"issuer": "@propelauth"})

const verifyNoIssuer = jwtLibrary.verify(jwt, secret);
console.log(verifyNoIssuer);
// { user_id: '8383', iat: 1634178110, iss: '@propelauth' }
// ^ this works because the library only checks the issuer if you ask it to

const verifyCorrectIssuer = jwtLibrary.verify(jwt, secret, {"issuer": "@propelauth"});
console.log(verifyCorrectIssuer);
// { user_id: '8383', iat: 1634178110, iss: '@propelauth' }
// ^ this works because the issuer matches

const verifyIncorrectIssuer = jwtLibrary.verify(jwt, secret, {"issuer": "oops"});
console.log(verifyIncorrectIssuer);
// JsonWebTokenError: jwt issuer invalid. expected: oops
// ^ this fails because the issuer doesn't match

A complete list of standard fields is available here. Almost every JWT library will support checking these standard fields.

What are JWT signing algorithms?

The last thing to explore in this library is the algorithms option. There are quite a few supported algorithms in the docs.

algorithms ultimately control the signing and verification functions we saw earlier. There’s a lot we can dig into here, but at a high level, there are two types of algorithms: symmetric and asymmetric.

The default algorithm (HS256) is symmetric, meaning the same secret is used for signing and verifying. We saw this above when we passed shhhhh into both sign and verify as the secret. This is often used when a service is verifying the JWTs they issue themselves.

Another common algorithm is RS256 which is asymmetric. In this case, a private key is used to sign, but a public key is used to verify. This is often used when the issuer and verifier are different. Anyone with the private key can create valid JWTs, so if a service is only verifying JWTs, they only need the public key.

It is good practice to specify the algorithm you are expecting in the verify function:

jwtLibrary.verify(jwt, secret);
// ^ don't do this

jwtLibrary.verify(jwt, secret, { algorithms: ['HS256'] });
// ^ do this

Why does this matter? Well, unfortunately none is a valid algorithm. There have been security flaws in applications when a person creates a fake token but uses the none algorithm (which expects there to be no signature).

Some libraries won’t allow none at all since it kind of defeats the purpose of verify.

Summing up

You should now have a pretty good grasp on JWTs based on this implementation. If you want to test your understanding, try reading the docs for a different popular JWT library (PyJWT is a good choice for python folks) and see if the interfaces make sense.