Learning Outcomes:
- how to create a JSON cookie
- what is digital signing
- how to use signing to prevent data tampering
- what are JSON Web Tokens
- how to use JSON Web Tokens
Featured npm Packages:
A very simple cookie (key and value) could look like logged_in=true
or username=druscilla
but these are not that useful for an application that requires any level of security.
There are two pieces of information about a user that are useful to store inside a cookie.
- Their user id.
- Their access privileges.
This will cover most of the requests that people will make to an application.
Therefore we should put them in our cookie. We could have something like data=45&admin
and parse it ourselves but it's easier to just use a JavaScript object (which will also make it simpler to add new fields in future).
const userInformation = {
userId: 45,
accessPrivileges: {
user: true,
admin: false
}
};
const cookieValue = JSON.stringify(userInformation);
req.setHeader('Set-Cookie', `data=${cookieValue}; HttpOnly; Secure`);
Great! We now have a cookie that we can add as much information to as we need, but there is still a big problem: This cookie can be very easily tampered with. (For example, by opening up the DevTools 'Application' tab and setting admin
to true
)
So when our server reads a cookie from an incoming request, how can we be sure that the cookie has not been edited?
It is possible to use hashing (the same thing we used to protect our passwords in Workshop 1), in order to protect our cookie from being altered.
In previous hashes we just had to compare the results, password to password. This is known as 'integrity' (ie, is the data the same). But for our cookie this isn't enough, we also need to verify the 'authentication' (ie, did we create the hash).
A HMAC (Hash-based message authentication code) is a way to hash a message in order to verify its integrity and authentication. This is important as we need to be sure that:
- We created the hash.
- The message has not changed.
A HMAC requires a secret
(random string), a value
(the string you want to protect) and mathematical algorithm
to apply to them.
These same three inputs will always produce the same result. So you can store the HMAC alongside the original message to verify in future, that the message/cookie/whatever has not been tampered with. This is known as 'signing'.
Luckily Node.js has a built-in HMAC function.
Note: When protecting a cookie, defence against brute force attacks (such as bcrypt
) is not necessary, for two reasons:
- Just gaining access to a valid cookie will give you access to all of that users privileges, without any further work.
- If you use a long, random string as a 'secret', with a modern hashing algorithm, there is not enough computing power on earth to crack that.
Now we are going to make an program for handling and verifying important communications.
You will be provided a Node.js module (to be used in your terminal), that accepts a 'secret' and returns an object with two functions on it, which you have to implement:
- sign: This function accepts a value (
String
), and uses the Node.js crypto module to create and return a HMACString
of that value usingSHA256
algorithm andhex
encoder. - validate: This function accepts a value (
String
), and a hash (String
). It calculates the HMAC of the value and compares it to the hash that was provided. It should return aBoolean
.
Here is an example of it in use:
const psst = require('./psst.js');
const { sign, validate } = psst('super secret string');
// Regular string
sign('winnie');
// 'f7f697686a57ed3308f7c536c8394ee55beb3540aab58340fba104a997b921ed'
// Or JSON string
sign('{"admin":true}');
// '97076541ba62ce457ef24935d67253227c6081a230150ac468ee9b8e132d2d01'
validate('woof', 'ijveorjgoerovoenboenbon'); // true or false
The file is in ./exercise-1/psst.js
.
To check your code, run node ./exercise-1/index.js
. The response should be a hash and true
.
HINT: There is an npm package (that gets 11 million downloads a month) that uses HMACs to sign their cookies. You should be able to get plenty of help from reading its source code. Look at how HMAC is being used. No copy pasting!
When you are done you can test it out by sharing a secret between two of you, and begin verifying messages from each other!
Whew! So now we know how to:
- Store plenty of information on our cookie.
- Prevent it from being tampered with.
We're done, right? One more thing...
This whole 'signed JSON' idea is such a good one that there is an entire open standard associated with it known as JSON Web Tokens.
JWT uses base64 encoding which is a way of converting binary data into plain text. Encoding is not the same as encrypting so sensitive information should not be stored within a JWT. We use JWTs for authentication and transferring data that you don't want to be tampered with.
The stucture of a JWT is a string, composed of three sections, joined together by full stops. The sections are:
1. Header - base64 encoded object about the type of token (jwt) and the type of hashing algorithm (ie HMAC SHA256).
{
alg: 'SHA256'
type: 'JWT'
}
2. Payload - base64 encoded object with your 'claims' in it. Mostly just a fancy name for the data you want to store in your JWT, but it can also hold 'reserved claims', which are some useful standard values, such as iss (issuer)
, exp (expiration time)
, sub (subject)
, and aud (audience)
.
{
"name": "John Doe",
"user": true,
"admin": false
}
3. Signature - a hash of parts 1)
and 2)
joined by a full stop.
hashFunction(`${encodedHeader}.${encodedPayload}`);
The overall structure of a JWT is:
[header].[payload].[signature]
Here is an example of a JWT:
eyJhbGciOiJIUzI1NiJ9.aGtqa2hr.IhQxjhZL2hMAR2MDKTD1hppR8KEO9cvEgsE_esJGHUA
So to build it in Node.js:
const base64Encode = str =>
Buffer.from(str).toString('base64');
const base64Decode = str =>
Buffer.from(str, 'base64').toString();
// Usually two parts:
const header = {
alg: 'SHA256', // The hashing algorithm to be used
typ: 'JWT' // The token 'type'
};
// Your 'claims'
const payload = {
userId: 99,
username: 'ada'
};
const encodedHeader = base64Encode(JSON.stringify(header));
const encodedPayload = base64Encode(JSON.stringify(payload));
const signature = hashFunction(`${encodedHeader}.${encodedPayload}`);
// 'Udcna0ETPpRw5m3po3COjicb_cGJvgtnoLZyLnftaaI'
const jwt = `${encodedHeader}.${encodedPayload}.${signature}`;
// Result!
// 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJvayI6dHJ1ZSwiaWF0IjoxNTAxOTY2MjY5fQ.Udcna0ETPpRw5m3po3COjicb_cGJvgtnoLZyLnftaaI'
This JWT is protected from tampering, because it is signed, but the payload and header are base64 encoded, which is basically plaintext (it's easy to convert back and forth). So do not store sensitive user information in a signed cookie, such as bank balance, DOB etc. To protect the information from being read, you will need to encrypt it, but this is rarely necessary.
The full JWT spec is rather large, so as fun as it would be to implement it ourselves like above, lets go with a library.
We will be using jsonwebtoken
, to create our JWTs. I also recommend using cookie
to parse your incoming req.headers.cookie
header.
Read the docs for both!
$ cd exercise-2
$ npm i
$ npm start
- Navigate to
localhost:3000
You will see that index.html
has three buttons, now you must implement the JWT cookie logic on the server side:
Endpoint | Action |
---|---|
/login |
Should create a cookie using jwt.sign , attach it to the response, and redirect to / |
/logout |
Should remove the cookie and redirect to / |
/auth_check |
Should check if the cookie exists, validate it with jwt.verify , and send back a 200 or 401 response with an informative message! |