The Question
While working on our checkout service, I noticed that we used JWTs to authorize client side requests. Authorization as opposed to Authentication because these requests are made between services and the client is already authenticated (logged into the platform). A co-worker asked a question about how JWT works and that got me spiraling down the rabbit hole.
I knew JWTs were used to tell the backend server that the request being made was made by a trusted party (ie. logged in user), but the mechanism was still a mystery to me. In contrast to JWT, traditional user session-based authorization uses cookies and sessions, but that is a stateful solution. Stateful because the session data is managed in the server and adds additional complexity.
In our architecture, I understood that the backend generates a token using a private key stored in AWS Key Management Service, which is then verified in our checkout service, but the underlying process was still nebulous. Specifically, it was unclear to me how the public key was used to determine that the token was coming from the right place.
Taking a Step Back
There are a few pre-requisite items to be familiar with that would solidify one’s understanding of the JWT mechanism.
- Encoding
- Hashing
- Encryption/Decryption with Public/Private keys
Encoding
Encoding is a reversible process of converting data from one form to another, typically to make it easier to transport. In our use case, we needed to encode some metadata – namely, the header, payload, and signature – to safely ferry it from our client to our server over HTTPS.
header = {
"alg": "RS256",
"typ": "JWT"
}
payload = {
"sub": "1234567890",
"name": "John Doe",
"admin": true,
"iat": 1516239022
}
encoded_result = base64urlEncoding(header) + '.' + base64urlEncoding(payload)
# "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0"
If we don’t encode the data we want to send, there could be data corruption/loss due to misinterpretation of byte values as the the data travels over the wire.
Without Base64 encoding, trying to send binary data to another system would be like trying to speak in a language that the listener may not understand. They might catch a few words, miss others entirely, or misconstrue the entire message. Base64 encoding ensures our data is heard correctly, without any mishaps along the way.
Hashing
Hashing is an irreversible process (or one-way function) of taking an input of any length and produce a fixed-length hash value. The length is determined by the SHA algorithm used. We use a SHA-256 algorithm that produces a 256-bit (32-byte) hash value.
Compared to encoding, hashing will protect the contents of your data as there is no way to reverse the hash. The best way is to guess random inputs via brute force. Here’s an incredible video from the channel 3Blue1Brown on how long it could take before you reach a successful hit. The most common use of hashing is for storing/verifying passwords. Companies store your hashed password and whenever you login with your password, they hash the plain-text to see if the resulting hash matches what’s stored in their database.
During the JWT authorization process, we send a digital signature alongside the encoded header and payload. This digital signature is generated in two steps:
- Hash the encoded header and payload via some hashing algorithm (SHA-256 for us).
- Encrypt the hashed value, which we will go into in the next section.
Hashing the result from the bas64 encoding step outputs the following (also in base64):
CISo+Rpkfwzkr7gx8kE1uTJOYpsmI1755ibtI8eHDQM=
Currently in the token generating process we have the following:
# Base64 encoded header and payload
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0"
# SHA-256 hashed result in Base64
"CISo+Rpkfwzkr7gx8kE1uTJOYpsmI1755ibtI8eHDQM="
Encryption
Encryption is a reversible process of transforming readable plain-text into unreadable ciphertext to protect sensitive information from unauthorized access.
It provides additional security to the JWT authorization process by ensuring that the token was generated by a valid party. We leveraged AWS KMS to create an asymmetric public/private key pair that can be used to decrypt/encrypt the JWT signature. It’s important that the private key is used to sign the signature since the owner of that key is responsible for issuing the JWT token (ie. the server responding to the client with the token). Anyone with the corresponding public key can verify the signature via decrypting the encrypted result back into the hashed value. When generating the JWT token, a private key (stored in AWS) is used to sign the hashed and encoded header / payload. Using KMS, the result looks as follows:
JWT.encode(encoded_result, AWS_KMS_PRIVATE_KEY, "RS256")
# resulting token to be sent to client
token =
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9 # base64 encoded header
.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0 # base64 encoded payload
.
NHVaYe26MbtOYhSKkoKYdFVomg4i8ZJd8_-RU8VNbftc4TSMb4bXP3l3YlNWACwyXPGffz5aXHc6lty1Y2t4SWRqGteragsVdZufDn5BlnJl9pdR_kdVFUsra2rWKEofkZeIC4yWytE58sMIihvo9H1ScmmVwBcQP6XETqYd0aSHp1gOa9RdUPDvoXQ5oqygTqVtxaDr6wUFKrKItgBMzWIdNZ6y7O9E0DhEPTbE9rfBo6KTFsHAZnMg4k68CDp2woYIaXbmYTWcvbzIuHO7_37GT79XdIwkm95QJ7hYC9RiwrV7mesbY4PAahERJawntho0my942XheVLmGwLMBkQ # encrypted signature
# Note that the encrypted signature isn't correct, but for illustrative purposes.
Once the client receives the token, it can make other requests with this token. This token can then be verified on the server as coming from the verified source via our public key. Your server would contain the necessary JWT package containing the verification function, but essentially it would do the following:
- First rehash the base64 encoded header + payload using the same algorithm used to generate the token. Ideally this would result in
CISo+Rpkfwzkr7gx8kE1uTJOYpsmI1755ibtI8eHDQM
. - Then decrypt the signature to get
CISo+Rpkfwzkr7gx8kE1uTJOYpsmI1755ibtI8eHDQM
. - Finally, compare the newly computed hash and the old hash. If they are equivalent, the verification is a success!
If the header, payload, or signature are tampered with at any point, the verification process would capture it since the hashes will not be equal.
Closing Thoughts
Here are some notes I drew out for my own understanding:
JWTs are a powerful stateless alternative to user based sessioning and a powerful tool when it comes to client to service authorization. Its built on top of a few abstractions, so peeling each layer allows one to have a better understanding of the overall structure and security.