The Wikipedia description of a JSON Web eToken (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 might this be useful?
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) {
// Can more gracefully handle errors
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 userIds.
jsonwebtoken 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 expiresIn
// ... 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 algorithms?
The last thing to explore in this library is the algorithms
option. There are quite a few supported algorithms in the docs.
The algorithms ultimately control the signing and verification functions. 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.
Top comments (1)
Nice 👍