JWTs are a popular form of authenticating users for web applications. I won’t get into the debate about whether or not we should be using JWTs for this purpose because that is a different discussion entirely. There are use cases that are better suited for using JWTs than others, and I am going to expect that you have landed here after already determining that JWTs are a good fit for what you hope to accomplish.
JWTs are a convenient way for us to store a small bit of public information generated by a server acting as the source of truth to be distributed to potentially anything. The server that generates and signs the JWT has access to a private key while the public key will need to be distributed to servers consuming the JWTs. By using asymmetrical keys we can ensure that everyone can read the information contained in the JWT while making sure that only the authentication server can sign those JWTs.
For simplicity, I will assume that the intent of signing and verifying JWTs is for authentication purposes. While JWTs can be used for other reasons, I imagine authentication is the widest used case and what anyone who found this article is planning on doing. I am also going to assume that you have none of this set up yet. I will walk you through the following: creating public and private keys, creating a JWKS, signing a JWT, and finally verifying a JWT.
One prerequisite for all of this is the npm package jose
Creating Public and Private Keys
I have seen a small amount of debate out on the interwebs about what algorithms should be used for creating your keys. From my point of view (and as of this writing) the best way to do this is using RSA. The JWA alg
for this is RS256
. There are two ways of generating these key pairs: using the command line and using jose
. My personal preference is to use jose
, but the command line tools are just as effective.
To generate the key pairs using the command line run the following commands.
ssh-keygen -t rsa -b 4096 -m PEM -f jwt.key -N ""
openssl rsa -in jwt.key -pubout -outform PEM -out jwt.key.pub
cat jwt.key
cat jwt.key.pub
To generate the key pairs using jose
run the following script or some variation of them. It would be beneficial to store the produced strings in files.
const { generateKeyPair } = require('jose/util/generate_key_pair');
(async () => {
const { publicKey, privateKey } = await generateKeyPair('RS256');
// https://nodejs.org/api/crypto.html#crypto_class_keyobject
const publicKeyString = publicKey.export({
type: 'pkcs1',
format: 'pem',
});
const privateKeyString = privateKey.export({
type: 'pkcs1',
format: 'pem',
});
console.log(publicKeyString);
console.log(privateKeyString);
})();
Both of these methods provide the same outcome. They generate RSA asymmetric keys. These are what allow us to properly sign a JWT.
The private key must not be given out to anything other than your authentication server. The private key is used to sign the JWTs and the JWT consumers use the public key to verify that the JWT came from our auth server, so if anyone else gains access to it, then they can pretend that they are our authentication server. This is obviously a no-no.
The way this all works is that the private key is used to encrypt the JWT. The public key can then be used to unencrypt the JWT. A public key can only unencrypt what its corresponding private key encrypted. No other public key can unencrypt the JWT. Conversely, no other private key can encrypt the same way that our new private key can. This means that whenever our new public key is used to successfully verify a JWT, we gain the knowledge that our authentication server approved the JWT’s authenticity from the start.
This is the premise of asymmetric encryption. While it’s not necessary to know anything more than what I outlined, it would not hurt to obtain a deeper understanding of this concept. Asymmetric encryption is a deep rabbit hole to go down though so learn at your own pace.
Back to the task at hand. As you might have guessed by now, the public key should be given out. This allows anyone to verify that the JWT given to them did in fact come from our authentication server. That’s a good thing. While it is valid to give out the public key in the form of a file (like what we just created) there is another type of “JSON Web Thing” that allows us to distribute the public key at scale: JWKS.
Creating a JWKS
JWKS stands for JSON Web Key Set and they are a convenient way to distribute public keys. Here is an example of a JWKS in the wild. The idea here is that JWKSs can be hosted as a simple JSON file to then be accessed by anything using a corresponding JWT. Building them off of public keys in jose
is just as easy as creating the initial key pairs.
If the public key is in a file (if it was created using the command line), then the file will need to be read in, converted to the Node crypto
library’s representation of a key, and converted using jose
.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { fromKeyLike } = require('jose/jwk/from_key_like');
const pubKeyPath = path.resolve(__dirname, 'path/to/jwt.key.pub');
const pubKey = fs.readFileSync(pubKeyPath, 'utf8');
const cryptoPublicKey = crypto.createPublicKey(pubKey);
fromKeyLike(cryptoPublicKey).then((publicJwk) => console.log(publicJwk));
We can also create these directly after generating the public key from jose
.
const { generateKeyPair } = require('jose/util/generate_key_pair');
const { fromKeyLike } = require('jose/jwk/from_key_like');
(async () => {
const { publicKey, privateKey } = await generateKeyPair('RS256');
// https://nodejs.org/api/crypto.html#crypto_class_keyobject
const publicKeyString = publicKey.export({
type: 'pkcs1',
format: 'pem',
});
const privateKeyString = privateKey.export({
type: 'pkcs1',
format: 'pem',
});
console.log(publicKeyString);
console.log(privateKeyString);
const publicJwk = await fromKeyLike(publicKey);
console.log(publicJwk);
})();
In both cases, the output should look similar to the following JSON Web Key.
{
"kty": "RSA",
"n": "wi5rLEy1U7m8rU8bQn2GeJ_g_XisJesbzI0N0QbYF3BNaEuQUwXOnh2ME8bOyKBrpXLik5AvljKp8HjwKG1x456kueJJoullYEBtrSRydnNaOQmUno1GQEcreCnRBZzodi9kw0YgsQvEfyxGwxI1NYSS8mdCSgT_BjOw5veHFfK-kdbJSa4mBkncKQCImArdAptKuvMciB3uSCfGqq1lZdBnsDR1O4isltDMBCLlAA9LQaXpksvQ99OROp965J0AFn9Vy64mwrhuonZ2c0C_dAAHJ_NSmmzI59xhB5QmCasINzGNNYBqxSzqRxCOpPcXt_D16il7nvsSIZoVuQDp3jsXO5fWONx5HtApO5zm7NpfSv800cFQjgQ8GHPqdVdufpKgAXaMxFlnLhgWP2QHTIpY2Mmy5zbKAEtTraWmYQs-cMhhj7sAzXNk6Wt25r9fyFVhmzGwhNmp4eUiVhiLKRTDuDSsFMaky__mtHcOEUZSvtyUEYQ2fqnHzlsBP4Ai8_Hr6d-qPQLmidR-69U5VQb6ftBGOeivzClSVRDfbKW7jtez2zB39FPx6Wm_FZqR1vBMmSNt1mJH2laIxkIh2qrkMCgLmMlkspZX8r3_VTRfUJcTcMWkzX16O8XzJeuI9YGgupF4K7wmzEmj1qZWgyXCSrB6L3873W8kPEui7lc",
"e": "AQAB"
}
We’re almost there. A JWKS is an array of JSON Web Keys that form the key set. It is possible to have multiple public keys in one JWKS that are identified by the kid
(Key ID) parameter. That being said, I have only ever seen single entry JWKSs. Putting our new JSON Web Key into a JWKS could look something like the following.
{
"keys": [
{
"use": "sig",
"kty": "RSA",
"n": "wi5rLEy1U7m8rU8bQn2GeJ_g_XisJesbzI0N0QbYF3BNaEuQUwXOnh2ME8bOyKBrpXLik5AvljKp8HjwKG1x456kueJJoullYEBtrSRydnNaOQmUno1GQEcreCnRBZzodi9kw0YgsQvEfyxGwxI1NYSS8mdCSgT_BjOw5veHFfK-kdbJSa4mBkncKQCImArdAptKuvMciB3uSCfGqq1lZdBnsDR1O4isltDMBCLlAA9LQaXpksvQ99OROp965J0AFn9Vy64mwrhuonZ2c0C_dAAHJ_NSmmzI59xhB5QmCasINzGNNYBqxSzqRxCOpPcXt_D16il7nvsSIZoVuQDp3jsXO5fWONx5HtApO5zm7NpfSv800cFQjgQ8GHPqdVdufpKgAXaMxFlnLhgWP2QHTIpY2Mmy5zbKAEtTraWmYQs-cMhhj7sAzXNk6Wt25r9fyFVhmzGwhNmp4eUiVhiLKRTDuDSsFMaky__mtHcOEUZSvtyUEYQ2fqnHzlsBP4Ai8_Hr6d-qPQLmidR-69U5VQb6ftBGOeivzClSVRDfbKW7jtez2zB39FPx6Wm_FZqR1vBMmSNt1mJH2laIxkIh2qrkMCgLmMlkspZX8r3_VTRfUJcTcMWkzX16O8XzJeuI9YGgupF4K7wmzEmj1qZWgyXCSrB6L3873W8kPEui7lc",
"e": "AQAB",
"kid": "3d911ijttg0k80u2k74ax0hxeuhnd9njad7oa6nf",
"alg": "RS256",
"key_ops": [
"verify"
]
}
]
}
There are a few tweaks that need to be made (either programmatically or manually). The first and most obvious is that a JWKS needs to have a top-level key named keys
with a value that is an array of JSON Web Keys. The added keys to the generated JSON Web Key are use
, kid
, alg
, and key_ops
which stand for Public Key Use, Key ID, Algorithm, and Key Operations, respectively.
use
can either be sig
(signature) or enc
(encryption). sig
indicates that the key is used for verifying the signature on data. enc
indicates that the key is used for encrypting data.
kid
is a unique string that I generated used to identify JSON Web Keys within a JWKS.
alg
identifies the algorithm intended for use with the key. This is the same value as what was passed into generateKeyPair
.
key_ops
identifies the intended operations upon which the JSON Web Key can be used. Some values for this array are sign
, verify
, and encrypt
among others. This is a link to the RFC defining the values for this key.
Once all of this is constructed, we have our JWKS. I suggested writing this information to a file and hosting it at an endpoint. It would also be completely valid to write it as a BLOB to a database and return it on request.
Signing a JWT
If we complete this piece correctly, then verifying our JWT will be a breeze. If something goes awry…well, let’s just hope that doesn’t happen. First, let’s go back to what we learned about asymmetric encryption in the first section. We use our private key to encrypt a payload and our public key (now in the form of a JWKS) to unencrypt the payload. To sign our JWT, this code will need access to the private key we created in the first section. In addition, we can set claims and create a payload for our JWT as outlined in the RFC.
(async () => {
const { publicKey, privateKey } = await generateKeyPair('RS256');
// https://nodejs.org/api/crypto.html#crypto_class_keyobject
const publicKeyString = publicKey.export({
type: 'pkcs1',
format: 'pem',
});
const privateKeyString = privateKey.export({
type: 'pkcs1',
format: 'pem',
});
const token = await new SignJWT({
myClaim: true,
})
.setProtectedHeader({
typ: 'JWT',
alg: 'RS256',
})
.setIssuer('https://example.com')
.setSubject('uniqueUserId')
.setAudience('myapp.com')
.setExpirationTime('6h')
.setIssuedAt()
.sign(privateKey);
console.log(token);
})();
The logged token should look something like this
eyJhbGciOiJSUzI1NiJ9.eyJteUNsYWltIjp0cnVlLCJpc3MiOiJodHRwczovL2V4YW1wbGUuY29tIiwic3ViIjoiYXNkZiIsImF1ZCI6Im15VXNlciIsImV4cCI6MTYyNjg5OTM3MCwiaWF0IjoxNjI2ODc3NzcwfQ.z7RSsY34eyO_sOsebR92M7P2piqoP9vRDw0kp2VUCkU-2ZcGeA2Jvf4GpJDSwmjxSuXSptYwnF-Qvw9A7hb6BH6XmH9ZG3bFLR-UEUjqjAKL5LRleh3EKES2LqVvng89p9xzFsDePTyzzVmc4yWV0fGC1-lMTLAmnDXxhRFIZZdyBbtHoxt7bmgrdCkk8jV0qVy-SoxWb0KvC8A24Pkkb7eWAS1CQDwVxBTWJDa9ixc0-eKSt2xtzw6jL8o_bkoAHJV2Zk1Cu04752Z9eAExdNq3zI6_wQkwap44MR0kpNF2pMPZz6kNLEUECt_QAzobV7WKYuPtkLLKN_67P-OaLg
The payload (which is anything custom that I want to be added to the JWT) is the piece that looks like { myClaim: true }
. The server gets to decide what it wants to add, and that payload will be available after verifying the JWT in another server. The other claims that are set are iss
(Issuer), sub
(Subject), aud
(Audience), exp
(Expiration Time), and iat
(Issued At). The protected header is pretty self-explanatory but there is more information and an example in the RFC.
iss
should be set to your service’s URL. For example, Crow Authentication sets the Issuer to https://api.crowauth.com
which is the API URL. This claim is optional.
sub
should uniquely identify the principal that is the subject of the JWT. This could be a user’s ID or email address. This claim is optional.
aud
should name the intended recipient of the JWT. For example, the application server that is requesting it. This claim is optional.
exp
represents the time at which the JWT should no longer be deemed valid. This claim is optional, but I highly recommend using it. For security reasons it is not a good idea to spit out a JWT that will forever validate the holder as being who they are. We need to either force the user to reauthenticate or use refresh tokens. Information abounds about reasons for using expiration claims and refresh tokens.
iat
simply claims when the JWT was signed.
Verifying a JWT
Remember that JWKS that we created a couple of sections ago? Now we get to use it. For the sake of keeping this guide focused on JWTs I am going to assume that either A) you have taken the time to create a working authentication server that can provide JWTs and hosts the JWKS (while this might require a decent amount of time to get set up, it is ultimately what we’re going for here) or B) you do not have a server set up. Situation A is best, but if you are limited on time or simply want to read code then I will provide small scripts to show the concepts working.
At a high level, we need to grab the distributed public key then verify the JWT provided to the application server, so under normal circumstances, this code would be run by an application server to verify the identity of the client. In the example, I will be using the JWKS that I have referenced a couple of times throughout this guide. Please substitute the URL for the appropriate JWKS which corresponds to the private key used by the authentication server that signed the JWT.
const { createRemoteJWKSet } = require('jose/jwks/remote');
const { jwtVerify } = require('jose/jwt/verify');
const jwks = createRemoteJWKSet(new URL('https://crowauth.com/v1/jwks.json'));
const { payload, protectedHeader } = await jwtVerify(jwt, jwks); // The jwt variable needs to be passed in from somewhere; cookie, hard coded, parameter, etc.
What happens here, is that jose
pulls the JWKS, finds the key based on use
being sig
, key_ops
including verify
, and some user-provided options. It then uses the JWK found from the JWKS to verify that the signature on the JWT did in fact come from the correct private key/authentication server. jwtVerify
can also take in which claims to verify like the Issuer or Audience. The payload
returned will contain the application-specific claims or information provided while signing the JWT. Based on my previous signing example, the payload
would be { myClaim: true }
.
In case you do not have a remote JWKS set up and the rest of the authentication server ready, here is a script that shows the same concept but at a local level.
(async () => {
const { publicKey, privateKey } = await generateKeyPair('RS256');
// https://nodejs.org/api/crypto.html#crypto_class_keyobject
const publicKeyString = publicKey.export({
type: 'pkcs1',
format: 'pem',
});
const privateKeyString = privateKey.export({
type: 'pkcs1',
format: 'pem',
});
const publicJwk = await fromKeyLike(publicKey);
const token = await new SignJWT({
myClaim: true,
})
.setProtectedHeader({
typ: 'JWT',
alg: 'RS256',
})
.setIssuer('https://example.com')
.setSubject('asdf')
.setAudience('myUser')
.setExpirationTime('6h')
.setIssuedAt()
.sign(privateKey);
const parsedJwk = await parseJwk(publicJwk, 'RS256'); // For the sake of the example, parse the JWK instead of simply using the generated publicKey
const { payload, protectedHeader } = await jwtVerify(token, parsedJwk);
console.log(protectedHeader);
console.log(payload);
})();
While parseJwk
is not entirely necessary (we could just use the publicKey
directly), this shows the same idea of verifying a JWT based on a JWK.
The End
You’ve done it. Good job. Hopefully, this guide made learning about JWTs with Javascript easy and you did not need to do much Googling on the side. At the beginning of the guide, I mentioned that JWTs are a popular form of authenticating users for web applications. Did you notice that I did not once talk about JWTs in the context of web applications? Whoops.
Understanding JWTs and how to operate with them was enough for this guide. I will be putting out another guide soon about using JWTs with web applications. That guide will cover how to authenticate users, where JWTs should be stored, and the relationships between the authentication server, browser, and application servers. Whenever it is written and published, I will edit this post with a link to it.
If you enjoyed this guide and it was helpful, please let me know somehow. Send an email, tweet at me, comment on one of the cross-posts (if you’re not reading this in my personal blog), whatever floats your boat. If it wasn’t helpful, there are errors, or you have suggestions, the same rule applies. If you need authentication for a new web application and this is your first introduction to it, might I suggest saving yourself the hassle and going with an off-the-shelf solution like Crow Authentication? Until next time!
Top comments (0)