JSON Web Tokens (JWT)#

ToDo:

  • Add diagram about how JWT is used - Similar to this

  • Add relevant resources at the end


JSON Web Tokens (JWT) is a concrete implementation of many concepts covered so far. It is basically a sequence of bytes (i.e. a token) which contains three parts:

  1. Header: Contains standard fields such as the algorithm (alg) to be used and the token type (typ).

  2. Payload: Payload is a name used in communications to refer to the “actual message”, anything that is not control, header, redundancy, the actual data being transmitted. There are standards fields such as the timestamp (iat), unique ID (jti) or expiration time (exp). However custom fields could be added as well.

  3. Signature: the digital signature signed by the party generating the JWT (usually a server), it is base64 encoded and could either use HMAC or RSA, as specified in the alg header.

There are standard fields both for the header and the payload but new user-defined fields can be added to the payload. Since the data in the payload is not necessarily true (i.e. one has to verify the signature first) the payload fields are usually refered to as “claims”. This is consistent with the concept of Claims-based identity.

When dealing with a single server, it might not be as useful because the party reading the claims will be the one who generated it. However, on microservices or multi service architecture, it would be possible to have claims generated by one service being verified by another service. For instance, if you logged in as Admin in the ERP module, you would also have admin access in the CRM module. Another example could be: a user logged as admin in the Client Module is not granted admin access to the Audit Module. This way services can all speak the same language (JWT), and verify the identity of the user and avoiding redundant look ups to databases, repetitive log ins or several implementations of security layers in different services.

To generate the token, the three parts are base64urlsafe (see Base64 Appendix) encoded can concatenate together using . as separators.

An example of a JWT:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

The Header and the Payload are both unencrypted and simply encoded in base64. The Signature (either HMAC based or RSA based), once computed, is also base64 encoded and concatenated with the other two encoded parts.

This gives JWTs the following features:

  • It supports symmetric and asymmetric signatures (via HMAC and RSA respectively).

  • It allows a user to have all the system related information centralized in one token.

  • It provides a standarized way to authenticate and communicate between applications.

HMAC Implementation using Native Python#

There are libraries to work with JWT, however implementing everything from scratch allows for a better understanding of each component, specially when re-using familiar concepts. Only using the stadndard library is enough to generate HMACs JWTs.

import secrets
import base64
import json
import hmac
import hashlib

Data#

Each JWT will have a header and a payload, in case of the HMAC based, a secret is also required.

headers = {
  "alg": "HS256",
  "typ": "JWT"
}

payload = {
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022
}

secret = secrets.token_bytes(16)

Headers#

Each of the parts should be encoded using base64 urlsafe

header_bytes = json.dumps(headers).encode("utf-8")
encoded_header = base64.urlsafe_b64encode(header_bytes).decode("utf-8")

print(f"Original Headers: {headers}")
print(f" Encoded Headers: {encoded_header}")
Original Headers: {'alg': 'HS256', 'typ': 'JWT'}
 Encoded Headers: eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9

Payload#

Each of the parts should be encoded using base64 urlsafe

payload_bytes = json.dumps(payload).encode("utf-8")
encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("utf-8")

print(f"Original Payload: {payload}")
print(f" Encoded Payload: {encoded_payload}")
Original Payload: {'sub': '1234567890', 'name': 'John Doe', 'iat': 1516239022}
 Encoded Payload: eyJzdWIiOiAiMTIzNDU2Nzg5MCIsICJuYW1lIjogIkpvaG4gRG9lIiwgImlhdCI6IDE1MTYyMzkwMjJ9

Signature#

Instead of passing the header and the payload object, the standard defines that the signature should be done on the encoded version of those parts. That means that the third part is a signature for the first two strings in the JWT.

message = (encoded_header + "." +  encoded_payload).encode("utf-8")
signature_bytes = hmac.digest(secret, message, digest=hashlib.sha256)

encoded_signature = base64.urlsafe_b64encode(signature_bytes).decode("utf-8")

print(f"Original Signature: {signature_bytes.hex()}")
print(f" Encoded Signature: {encoded_signature}")
Original Signature: 5ef52c3eb14dac3ea5fb6b0871bcbfc1b47fc5b65ad6ae639eb88557ba7463d7
 Encoded Signature: XvUsPrFNrD6l-2sIcby_wbR_xbZa1q5jnriFV7p0Y9c=

Token#

Once all components are ready, building the token is simply concanetating the parts with ..

token = f"{encoded_header}.{encoded_payload}.{encoded_signature}" 
print(f"JWT: {token}")
JWT: eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJzdWIiOiAiMTIzNDU2Nzg5MCIsICJuYW1lIjogIkpvaG4gRG9lIiwgImlhdCI6IDE1MTYyMzkwMjJ9.XvUsPrFNrD6l-2sIcby_wbR_xbZa1q5jnriFV7p0Y9c=

Verify#

Once the token is ready, it is crucial to have a way to verify the signature.

def verify(token, secret):
    encoded_header, encoded_payload, encoded_signature = token.split(".")
    message = (encoded_header + "." + encoded_payload).encode("utf-8")
    
    computed_signature = hmac.digest(secret, message, digest=hashlib.sha256)
    
    padding_correction = "=" * ((4 - len(encoded_signature) % 4) % 4)
    received_signature = base64.urlsafe_b64decode(encoded_signature + padding_correction)

    if hmac.compare_digest(computed_signature, received_signature):
        return "The token is Valid"
    return "The token is NOT Valid"

Changing Headers#

If any of the headers are changed, or a new header is added, the verification will fail.

tampered_headers = {
  "alg": "HS256",
  "typ": "JWT",
  "extra": 1
}

tampered_headers_bytes = json.dumps(tampered_headers).encode("utf-8")
encoded_tampered_header = base64.urlsafe_b64encode(tampered_headers_bytes).decode("utf-8")

_, encoded_payload, encoded_signature = token.split(".")
tampered_token = f"{encoded_tampered_header}.{encoded_payload}.{encoded_signature}" 

verification = verify(tampered_token, secret)
print(verification)
The token is NOT Valid

Changing Payload#

The same applies to the payload, this is consistent with what was shown in the Asymmetric Chapter.

tampered_payload = {
  "sub": "1234567890",
  "name": "Alice",
  "iat": 1516239022
}

tampered_payload_bytes = json.dumps(tampered_payload).encode("utf-8")
encoded_tampered_payload = base64.urlsafe_b64encode(tampered_payload_bytes).decode("utf-8")

encoded_header, _, encoded_signature = token.split(".")
tampered_token = f"{encoded_header}.{encoded_tampered_payload}.{encoded_signature}" 

verification = verify(tampered_token, secret)
print(verification)
The token is NOT Valid

Changing Signature#

As seen in the HMAC chapter the only way to generate a valid signature that will be verifiable by two parties is when both have the same secret. Generating a new signature with a different secret will not work and the token will no be valid.

guess_secret = b"admin"

encoded_header, encoded_payload, _ = token.split(".")
message = (encoded_header + "." +  encoded_payload).encode("utf-8")

signature_bytes = hmac.digest(guess_secret, message, digest=hashlib.sha256)
encoded_tampered_signature = base64.urlsafe_b64encode(signature_bytes).decode("utf-8")

tampered_token = f"{encoded_header}.{encoded_payload}.{encoded_tampered_signature}" 

verification = verify(tampered_token, secret)
print(verification)
The token is NOT Valid

Changing Two Parts#

Changing a single part is enough for the token to be invalid, having changed two or more would have thrown the same errors.

The secret the verfier will use to verify the token is extremely likely (yet not impossible) to differ from the one a potential attacker might use. The only possibility to alter the JWT is by having the correct secret beforehand.

Using Original Token Parts#

When the original header, payload and signature are intact, the signature is verified and the token can be trusted.

verification = verify(token, secret)
print(verification)

_, encoded_payload, _ = token.split(".")
decoded_payload = base64.urlsafe_b64decode(encoded_payload)
decoded_payload = json.loads(decoded_payload)

print(f"Decoded Payload: {decoded_payload}")
print(f"   Payload Type: {type(decoded_payload)}")
The token is Valid
Decoded Payload: {'sub': '1234567890', 'name': 'John Doe', 'iat': 1516239022}
   Payload Type: <class 'dict'>

HMAC Implementation using a Library#

The Python JOSE library has support for many JWT algorithms, including HMAC and RSA versions. It also handle many edge cases that would be otherwise complicated to handle with a from scratch implementation.

It is possible to use it with different backends:

The Cryptography library was the one used for the Symmetric and Asymmetric chapters and is the most popular one, therefore it will be the one used in this chapter.

To install Python JOSE simply run

pip install python-jose[cryptography]
from jose import jwt
---------------------------------------------------------------------------
ModuleNotFoundError                       Traceback (most recent call last)
Cell In[12], line 1
----> 1 from jose import jwt

ModuleNotFoundError: No module named 'jose'

Supported Algorithms#

This is the list of all supported algorithms in Python JOSE

", ".join(jwt.ALGORITHMS.SUPPORTED)
'RSA1_5, A256KW, HS256, A192CBC-HS384, ES256, RSA-OAEP-256, A256GCM, HS384, A128GCM, HS512, A256CBC-HS512, ES512, dir, A192GCM, A128CBC-HS256, A128KW, ES384, A192KW, RS256, RSA-OAEP, RS384, RS512'

Data#

The same data is used as in the native implementation.

Generating Token#

Python JOSE exposes a function encode that base64 encodes the headers, the payload and generated an appropiate signature with a given secret. This is much more compact than the native approach.

token = jwt.encode(claims=payload, key=secret, headers=headers, algorithm="HS256")
print(f"JWT: {token}")
JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.ezvVIGAaFOrUEWgJSOwDrMyqd-OYTFfv44fADqJt0E0

Verifing Token#

In this case the method decode is used, all the verification logic is handle seemlessly.

from jose.exceptions import JWTError

def verify_jose(token, secret):
    try:
        jwt.decode(token, key=secret, algorithms=["HS256"])
        return "The token is Valid"
    except JWTError:
        return "The token is NOT valid"

Trying to tamper the token#

Since only the implementation changed, the same considerations apply as above regarding changing the headers, the payload or the signature.

Using Original Token Parts#

verification = verify_jose(token, secret)
print(verification)

decoded_payload = jwt.decode(token, key=secret, algorithms=["HS256"])

print(f"Decoded Payload: {decoded_payload}")
print(f"   Payload Type: {type(decoded_payload)}")
The token is Valid
Decoded Payload: {'sub': '1234567890', 'name': 'John Doe', 'iat': 1516239022}
   Payload Type: <class 'dict'>

Using RSA#

Since Python does not have native support for Asymmetric encryption only library based solutions will be used. One using Cryptography alone and the other using Python JOSE.

Common Data#

Both Cryptography based and Python JOSE Based will use the same headers, payloads and Key Pair to compare the resulting JWTs.

from cryptography.hazmat.primitives.asymmetric import rsa
def generate_key_pair():
    key_size = 2048  # Should be at least 2048

    private_key = rsa.generate_private_key(
        public_exponent=65537,  # Do not change
        key_size=key_size,
    )

    public_key = private_key.public_key()
    return private_key, public_key
headers = {
  "alg": "RS256",  # Changed to use RSA
  "typ": "JWT"
}

payload = {
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022
}

private_key, public_key = generate_key_pair()

Using Cryptography alone#

from cryptography.hazmat.primitives import serialization, hashes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.exceptions import InvalidSignature

Signature#

Encoding Headers and Payload#
header_bytes = json.dumps(headers).encode("utf-8")
encoded_header = base64.urlsafe_b64encode(header_bytes).decode("utf-8")

payload_bytes = json.dumps(payload).encode("utf-8")
encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("utf-8")
Signing#
message = (encoded_header + "." +  encoded_payload).encode("utf-8")

signature_bytes = private_key.sign(
                      message,
                      padding.PKCS1v15(),
                      hashes.SHA256()
                  )

encoded_signature = base64.urlsafe_b64encode(signature_bytes).decode("utf-8")

print(f"Original Signature: {signature_bytes.hex()}")
print(f" Encoded Signature: {encoded_signature}")
Original Signature: 37237fa50a3e5b37850e2b3c521f59671faa4d8de23d420457c925b54607f09b3cdf2c716edfbe7e05fd196bb1bbe3909d902a420c1487ae05f8d9797b7cb11509fdd3d942854e55b3df24d27fccc129ff9ee4ea5066c66756cda9bbceeb5ad5b6ee114195754990bf50a419c9e35c21a53b27a79c9abd6561e1d409522b8b24240d83d99954dcb2637cd259525b4e4fbc0cbf7e8eeaf53c20262a03ccc94ad05fa7364cefc89dc2b185654b095fa8b302a209792613db303f2abe5478a6ad36ee55f44b6e525a788dfa8331ef45de5532224238b50feeb40f41c72e5ccff1c5997c5b8b23d33f3bf69fe7a8e9b762269f4e77f16544c114426f661c100886bb
 Encoded Signature: NyN_pQo-WzeFDis8Uh9ZZx-qTY3iPUIEV8kltUYH8Js83yxxbt--fgX9GWuxu-OQnZAqQgwUh64F-Nl5e3yxFQn909lChU5Vs98k0n_MwSn_nuTqUGbGZ1bNqbvO61rVtu4RQZV1SZC_UKQZyeNcIaU7J6ecmr1lYeHUCVIriyQkDYPZmVTcsmN80llSW05PvAy_fo7q9TwgJioDzMlK0F-nNkzvyJ3CsYVlSwlfqLMCogl5JhPbMD8qvlR4pq027lX0S25SWniN-oMx70XeVTIiQji1D-60D0HHLlzP8cWZfFuLI9M_O_af56jpt2Imn0538WVEwRRCb2YcEAiGuw==

Token#

token = f"{encoded_header}.{encoded_payload}.{encoded_signature}" 
print(f"JWT: {token}")
JWT: eyJhbGciOiAiUlMyNTYiLCAidHlwIjogIkpXVCJ9.eyJzdWIiOiAiMTIzNDU2Nzg5MCIsICJuYW1lIjogIkpvaG4gRG9lIiwgImlhdCI6IDE1MTYyMzkwMjJ9.NyN_pQo-WzeFDis8Uh9ZZx-qTY3iPUIEV8kltUYH8Js83yxxbt--fgX9GWuxu-OQnZAqQgwUh64F-Nl5e3yxFQn909lChU5Vs98k0n_MwSn_nuTqUGbGZ1bNqbvO61rVtu4RQZV1SZC_UKQZyeNcIaU7J6ecmr1lYeHUCVIriyQkDYPZmVTcsmN80llSW05PvAy_fo7q9TwgJioDzMlK0F-nNkzvyJ3CsYVlSwlfqLMCogl5JhPbMD8qvlR4pq027lX0S25SWniN-oMx70XeVTIiQji1D-60D0HHLlzP8cWZfFuLI9M_O_af56jpt2Imn0538WVEwRRCb2YcEAiGuw==

Verify Token#

def verify_rsa_cryptography(token, public_key):
    encoded_header, encoded_payload, encoded_signature = token.split(".")
    
    padding_correction = "=" * ((4 - len(encoded_signature) % 4) % 4)
    received_signature = base64.urlsafe_b64decode(encoded_signature + padding_correction)
    
    received_message = f"{encoded_header}.{encoded_payload}".encode("utf-8")
    
    try:
        public_key.verify(
            received_signature,
            received_message,
            padding.PKCS1v15(),
            hashes.SHA256()
        )
        return "The token is Valid"
    except InvalidSignature:
        return "The token is NOT Valid"   
verify_rsa_cryptography(token, public_key)
'The token is Valid'
Tampering Scenarios#

The same tampering experiments as above could be tested: changing the headers, changing the payload or changing the signature. Since the code would be exactly the same, those cases are not shown for the RSA based JWT.

Using Python JOSE#

Generating the Token#

private_pem_bytes = private_key.private_bytes(
   encoding=serialization.Encoding.PEM,
   format=serialization.PrivateFormat.PKCS8,
   encryption_algorithm=serialization.NoEncryption()
)

token = jwt.encode(claims=payload, key=private_pem_bytes, headers=headers, algorithm='RS256')
print(f"JWT: {token}")
JWT: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.N36K2oSJa74lm-l5NGhB_HQB6EzoiVF0zf2oPDESojApdCkgqYkzLMF444RgYlQ1iNToOhDlV09RX7qYDaft6izHVjCt2YaiCZJRqNil4yQGxBbwocbD6xLal0ZM2Y4cjpip3WaCbDBxZrWJOeIkMWH4cyZZ8cb8o4VFhxcfgzdmSfqyLr1Z_7KTmQrGJmHmR--HkFkHXJuOfoej-wQYSWuShbPyxn6ZjJyG1IglpXtuWE0Wcp9SJP_21rtLpIqZwa7UaP71_JE3N1X3ygbhq8ALouPK_2Dt2YqBuMv5KYkfC4AuAh0itPpRj9VThcHwnKDSyBAYPYR1m1Z_7fpqpQ

Verify the Token#

def verify_rsa_jose(token, public_key_bytes):
    try:
        jwt.decode(token, key=public_key_bytes, algorithms=['RS256'])
        return "The token is Valid"
    except JWTError:
        return "The token is NOT Valid"
public_pem_bytes = public_key.public_bytes(
   encoding=serialization.Encoding.PEM,
   format=serialization.PublicFormat.SubjectPublicKeyInfo,
)

verification = verify_rsa_jose(token, public_pem_bytes)
print(verification)
The token is Valid
Tampering Scenarios#

The same tampering experiments as above could be tested: changing the headers, changing the payload or changing the signature. Since the code would be exactly the same, those cases are not shown for the RSA based JWT.

Native vs Library#

It is NOT recommended to use any native implementation for anything security related. Instead, always used time proved, well tested libraries.

This is because there might be new vulnerabilities discovered and the library will likely get a patch or an update quick enough, whereas in the case of the native implementation, it is hard to update already-running-in-production solutions.

That being said and just for completeness, a time benchmark is shown below.

Cross Compatibility#

Using HMAC#

It is possible to encode with the native implementation and decode with the library and the other way around, that is because the JWT is a standard.

def create_token_native(headers, payload, secret):
    header_bytes = json.dumps(headers).encode("utf-8")
    encoded_header = base64.urlsafe_b64encode(header_bytes).decode("utf-8")
    
    payload_bytes = json.dumps(payload).encode("utf-8")
    encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("utf-8")
    
    message = (encoded_header + "." +  encoded_payload).encode("utf-8")
    signature_bytes = hmac.digest(secret, message, digest=hashlib.sha256)

    encoded_signature = base64.urlsafe_b64encode(signature_bytes).decode("utf-8")
    
    return f"{encoded_header}.{encoded_payload}.{encoded_signature}"


def create_token_jose(headers, payload, secret):
    return jwt.encode(claims=payload, key=secret, headers=headers, algorithm="HS256")
headers = {
  "alg": "HS256",
  "typ": "JWT"
}

payload = {
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022
}

secret = secrets.token_bytes(16)

Encode with Native decode with Library#

token = create_token_native(headers, payload, secret)
verify_jose(token, secret)
'The token is Valid'

Encode with Library decode with Native#

token = create_token_jose(headers, payload, secret)
verify(token, secret)
'The token is Valid'

Using RSA#

When dealing with RSA, there is also cross compatibility between Cryptography and Python JOSE. This is non-trivial since Python JOSE does not use Cryptography as backend for RSA.

def create_token_rsa_cryptography(headers, payload, private_key):
    header_bytes = json.dumps(headers).encode("utf-8")
    encoded_header = base64.urlsafe_b64encode(header_bytes).decode("utf-8")

    payload_bytes = json.dumps(payload).encode("utf-8")
    encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("utf-8")

    message = (encoded_header + "." +  encoded_payload).encode("utf-8")

    signature_bytes = private_key.sign(message, padding.PKCS1v15(), hashes.SHA256())

    encoded_signature = base64.urlsafe_b64encode(signature_bytes).decode("utf-8")

    return f"{encoded_header}.{encoded_payload}.{encoded_signature}" 


def create_token_rsa_jose(headers, payload, private_pem_bytes):
    return jwt.encode(claims=payload, key=private_pem_bytes, headers=headers, algorithm='RS256')
headers = {
  "alg": "RS256",
  "typ": "JWT"
}

payload = {
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022
}

private_key, public_key = generate_key_pair()

private_pem_bytes = private_key.private_bytes(
   encoding=serialization.Encoding.PEM,
   format=serialization.PrivateFormat.PKCS8,
   encryption_algorithm=serialization.NoEncryption()
)

public_pem_bytes = public_key.public_bytes(
   encoding=serialization.Encoding.PEM,
   format=serialization.PublicFormat.SubjectPublicKeyInfo,
)

Encode with Cryptography decode with Python JOSE#

token = create_token_rsa_cryptography(headers, payload, private_key)
verify_rsa_jose(token, public_pem_bytes)
'The token is Valid'

Encode with Python JOSE decode with Cryptography#

token = create_token_rsa_jose(headers, payload, private_pem_bytes)
verify_rsa_cryptography(token, public_key)
'The token is Valid'

HMAC Time Benchmark#

Generating the token#

print("Native:", end=" ")
%timeit create_token_native(headers, payload, secret)
Native: 37.9 µs ± 2.88 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)
print("  Jose:", end=" ")
%timeit create_token_jose(headers, payload, secret)
  Jose: 104 µs ± 5.15 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)

Verifying the token#

token = create_token_jose(headers, payload, secret)
print("Native:", end=" ")
%timeit verify(token, secret)
Native: 18 µs ± 1.14 µs per loop (mean ± std. dev. of 7 runs, 100000 loops each)
print("  Jose:", end=" ")
%timeit verify_jose(token, secret)
  Jose: 32.1 µs ± 4.61 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)

RSA Time Benchmark#

Generating the token#

print("Cryptography:", end=" ")
%timeit create_token_rsa_cryptography(headers, payload, private_key)
Cryptography: 2.3 ms ± 268 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
print(" Python Jose:", end=" ")
%timeit create_token_rsa_jose(headers, payload, private_pem_bytes)
 Python Jose: 14.3 ms ± 549 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

Verifying the token#

token = create_token_rsa_jose(headers, payload, private_pem_bytes)
print("Cryptography:", end=" ")
%timeit verify_rsa_cryptography(token, public_key)
Cryptography: 158 µs ± 6.23 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)
print(" Python Jose:", end=" ")
%timeit verify_rsa_jose(token, public_pem_bytes)
 Python Jose: 418 µs ± 39.5 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

Caveats: Other JWT Features#

One important remark of JWT is that the validity of the signature is not the only check that should be passed to make the JWT valid. As an example it is possible to define an experitation date or a not-valid-before date. Those checks are automatically checked by Python JOSE but are missing in the Native and the Cryptography implementations since the only part implemented was the signature verification.

from datetime import datetime, timedelta
headers = {
  "alg": "HS256",
  "typ": "JWT"
}

secret = secrets.token_bytes(16)

this_moment = datetime.now()
issue_date = (this_moment - timedelta(hours=24)).timestamp()

Not Valid Before Date#

If the nbf field is set to some time in the future (e.g. in 2hs) the token will not be valid until then.

start_since = (this_moment + timedelta(hours=2)).timestamp()

payload = {
  "name": "John Doe",
  "iat": issue_date,
  "nbf": start_since
}

token = create_token_native(headers, payload, secret)

Since the verify function only checks the signature, the token will be valid

verify(token, secret)
'The token is Valid'

However, Python JOSE has this as a native feature already and thus it will identify the JWT as invalid.

verify_jose(token, secret)
'The token is NOT valid'

Expiration Date#

If instead a hard expiration date for the token is provided by the exp claim, the token will not be valid after that. It is always recommended to have this field, users can always re-generate JWTs if needed.

expiration = (this_moment - timedelta(hours=2)).timestamp()

payload = {
  "name": "John Doe",
  "iat": issue_date,
  "exp": expiration
}

token = create_token_native(headers, payload, secret)

Since the verify function only checks the signature, the token will be valid

verify(token, secret)
'The token is Valid'

However, Python JOSE has this as a native feature already and thus it will identify the JWT as invalid.

verify_jose(token, secret)
'The token is NOT valid'

Comparison Conclusion#

Regardless of the particular implementation, RSA based JWT was much slower than HMAC based. This corresponds to symmetric encryption being much faster than asymmetric.

Even though the Native implementation was faster than the Python JOSE one, it is NOT recommended to use any custom solution for security related problems. The sames applies to the comparison between Cryptography and Python JOSE. This can be seen by Python JOSE implementing other checks that were not present in the Native and Cryptography implementations.

Python JOSE is a library specifically developed for JWT manipulation and therefore should be the go to approach. However, other JWT libraries could also be used if they are time proved and well tested.

Example: User Authentication#

The following example showcases how JWTs can be used to securely login users. This example is based on the Salt Chapter’s.

The scenario is having two tables: users and roles. Each user will have its password hashed with salt and then on other unencrypted table, each user will have a role. There is a site to which only users with the admin role can access. In this example the authorization to that site is handled by JWTs. The main advantage is that once the JWT was generated, all relevant (non-secret) information of the user is saved there, meaning that JWT is specially useful for stateless APIs).

This example uses HMAC but the same could be achieved using RSA.

Note: Remember that this code is not suitable for any production use-case.

Auxiliary Functions#

import os
os.environ["SECRET_JWT"] = secrets.token_hex(32)

def generate_hash(data:str, salt: bytes) -> str:
    data_bytes = data.encode("utf-8")
    data_hashed = hashlib.scrypt(data_bytes, salt=salt, n=64, r=8, p=1)
    return f"{salt.hex()}:{data_hashed.hex()}"


def sign_up(email, password, database_):
    database = database_.copy()
    random_bytes = secrets.token_bytes(10)
    database[email] = generate_hash(password, random_bytes)
    print(f"Successfully Singed Up: {email}")
    return database


def login(email, password, database, roles):
    if email not in database:
        print(f"ERROR: User {email} not in Database")
        return

    expected_password = database[email]
    salt, hashed = expected_password.split(":")
    salt_bytes = bytes.fromhex(salt)
    calculated_hash = generate_hash(password, salt_bytes)
    passwords_matched = secrets.compare_digest(expected_password, calculated_hash) 
    
    if not passwords_matched:
        print(f"ERROR: Incorrect Password for: {email}")
        return
        
    headers = {
      "alg": "HS256",
      "typ": "JWT"
    }
    
    this_moment = datetime.now()

    payload = {
      "email": email,
      "role": roles[email],
      "iat": this_moment.timestamp(),
      "exp": (this_moment + timedelta(seconds=10)).timestamp()
    }

    secret = os.environ.get("SECRET_JWT")

    print(f"Successfully Logged in: {email}")
    return jwt.encode(claims=payload, key=secret, headers=headers, algorithm="HS256")
    
    
def access_restricted_site(token):
    secret = os.environ.get("SECRET_JWT")
    try:
        payload = jwt.decode(token, secret, algorithms=["HS256"])
        role = payload["role"]
        if role == "admin":
            return "Access Granted"
        return "You are logged in but you do not have access to this site"
    except JWTError:
        return "Your token is invalid, log in first"

Sign Up#

roles = {
    "johndoe@example.com": "user",
    "alicedoe@example.com": "admin",
}
email = "johndoe@example.com"
password = "password123"
user_database = {}

user_database = sign_up(email, password, user_database)

email = "alicedoe@example.com"
password = "adminadmin"
user_database = sign_up(email, password, user_database)
Successfully Singed Up: johndoe@example.com
Successfully Singed Up: alicedoe@example.com

Log In#

Not Enough Priviliges#

John logs in and tries to access the restricted site.

email = "johndoe@example.com"
password = "password123"

john_jwt_token = login(email, password, user_database, roles)

authentication_message = access_restricted_site(john_jwt_token)

print(authentication_message)
Successfully Logged in: johndoe@example.com
You are logged in but you do not have access to this site

Tampering Priviliges#

John can decode his token and see that the role he was assigned is user and not admin, he could try to change that and generate a new token. However as shown above, changing only the payload will invalidate the token and since John does not have the secret, it cannot update the signature.

original_header, original_payload, original_signature = john_jwt_token.split(".")

padding_correction = "=" * ((4 - len(original_payload) % 4) % 4)

decoded_payload = base64.urlsafe_b64decode(original_payload + padding_correction)
decoded_payload_dict = json.loads(decoded_payload)
decoded_payload_dict["role"] = "admin"
tampered_payload = json.dumps(decoded_payload_dict).encode("utf-8")
tampered_payload_encoded = base64.urlsafe_b64encode(tampered_payload)

tampered_token = f"{original_header}.{tampered_payload_encoded}.{original_signature}"

authentication_message = access_restricted_site(tampered_token)

print(authentication_message)
Your token is invalid, log in first

Having Enough Priviliges#

For Alice the process is seemless and she can access without any problems

email = "alicedoe@example.com"
password = "adminadmin"

alice_jwt_token = login(email, password, user_database, roles)

authentication_message = access_restricted_site(alice_jwt_token)

print(authentication_message)
Successfully Logged in: alicedoe@example.com
Access Granted

Token Expired#

The token was configured to expire in 10s, this is an artificially small amount of time just for demonstration purposes. Alice was able to access the restricted site but now, 10 seconds after, the token is not valid any more.

import time

time.sleep(10)
authentication_message = access_restricted_site(alice_jwt_token)

print(authentication_message)
Your token is invalid, log in first

Caveats#

If John get Alice’s JWT by any means, he could have access to the restricted site. This risk can be mitigated by using certificate based authentication or strong security measures of the JWT.

The JWT is ultimately an access card, anyone who has it, can acts as if it were the user to whom it was originally given. That is why other measure such as certificates can be add to avoid misuse of JWTs.

Conclusion#

JSON Web Tokens (JWT) combines several cryptography concepts in a practical way, it allows to generate tokens, that are easy to share via HTTP and that serves for authentication of users. They also allow for custom fields, giving extra flexibility.

JWTs are compatible with both symmetric and asymmetric digital signatures by using HMAC and RSA respectively. When one the party generating the token should verify it, HMAC is the most convenient solution, if everone should be able to verify the signature RSA is most convenient. There are many other algorithms supported by the JWT standard but those two are the most popular ones.

In this chapter a Native and a Library based implementation were shown, the native one should not be used by any means and it is only for illustration purposes.

Using JWTs is becoming more and more common nowadays with the rise of Stateless API architectures and microservices. And is widely use in the Open Authorization (OAuth)

Additional Resources#

  • JWT.io: a free online service that lets you decode, verify and generate JWT

  • Other JWT Libraries for Python: pyjwt, jwcrypto and authlib