Cloudflare Access and JWTs

Cloudflare does a lot of cool things. One of them is access control and authentication. In Amedia we use them to gate internal tooling, dashboards, and apps.

But how do you actually verify a Cloudflare JWT token? JWTs can be a quite daunting topic with lots of foreign jargon, and strings of cryptographically encoded keys.

Luckily, decoding a JWT is easy in most environments (because in most environments you'll have a library handle it for you).

But first...

What is a JWT?

JWT stands for JSON Web Token. It's a standard for encoding data in a token that can be easily shared between two parties. It's often used for authentication and authorization.

It's constituent parts are:

  • Header: Contains metadata about the token (like the type and the hashing algorithm used).
  • Payload: Contains the data you want to share.
  • Signature: A cryptographic signature that ensures the token hasn't been tampered with.

Example

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjEyMzQ1In0.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.NHVaYe26MbtOYhSKkoKYdFVomg4i8ZJd8_-RU8VNbftc4TSMb4bXP3l3YlNWACwyXPGffz5aXHc6lty1Y2t4SWRqGteragsVdZufDn5BlnJl9pdR_kdVFUsra2rWKEofkZeIC4yWytE58sMIihvo9H1ScmmVwBcQP6XETqYd0aSHp1gOa9RdUPDvoXQ5oqygTqVtxaDr6wUFKrKItgBMzWIdNZ6y7O9E0DhEPTbE9rfBo6KTFsHAZnMg4k68CDp2woYIaXbmYTWcvbzIuHO7_37GT79XdIwkm95QJ7hYC9RiwrV7mesbY4PAahERJawntho0my942XheVLmGwLMBkQ

The above string consists of three parts if you split on the dots (.). Each part is base64 encoded.

Header

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjEyMzQ1In0

If you decode it, you'll get:

{
  "alg": "RS256",
  "kid": "12345",
  "typ": "JWT"
}

Payload

eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0

If you decode it, you'll get:

{
   "sub": "1234567890",
    "name": "John Doe",
    "admin": true,
    "iat": 1516239022
  }

Signature

NHVaYe26MbtOYhSKkoKYdFVomg4i8ZJd8_-RU8VNbftc4TSMb4bXP3l3YlNWACwyXPGffz5aXHc6lty1Y2t4SWRqGteragsVdZufDn5BlnJl9pdR_kdVFUsra2rWKEofkZeIC4yWytE58sMIihvo9H1ScmmVwBcQP6XETqYd0aSHp1gOa9RdUPDvoXQ5oqygTqVtxaDr6wUFKrKItgBMzWIdNZ6y7O9E0DhEPTbE9rfBo6KTFsHAZnMg4k68CDp2woYIaXbmYTWcvbzIuHO7_37GT79XdIwkm95QJ7hYC9RiwrV7mesbY4PAahERJawntho0my942XheVLmGwLMBkQ

The signature makes no sense to decode, as it's a cryptographic hash of the header and payload. It's used to verify the integrity of the token.

Verifying this signature manually requires the public key that corresponds to the private key used to sign the token. Doing it manually is out of scope of this article but you can find resources on how to do it here: JWT.io and here: The JWT Handbook.

Cloudflare Access

Cloudflare Access provides two endpoints that help you with the decoding of JWTs.

  • https://<team>.cloudflareaccess.com/cdn-cgi/access/certs: Gives you the public keys for each application in that team
  • https://<team>.cloudflareaccess.com/cdn-cgi/access/get-identity : Gives you the data pertaining to the user that the JWT belongs to.

Cloudflare also provides (in the dashboard), application ID tags. These tags can be used to verify which application the JWT is signed with.

Using a Library

There are numerous libraries that make your life easier when handling JWTs. JWT.io has an extensive list of libraries for every environment, with an overview of what features they support.

Following is an example on how to decode and use the Cloudflare Access endpoints in Python using python-jose.

import os

import requests
from fastapi import Cookie, HTTPException
from jose import jwk, jwt, JWTError

# Application Audience (AUD) tag for your application
# This is the Cloudflare Application tag for the given application e.g. "secure.<team>.cloud"
# The Application tag should be a secret that you get through env or similar.
# It is possible to verify a signed JWT without the audience tags,
# in which instance there are some caveats with regards to security.
# See verify_token below for how this works in practice
AUD = os.getenv("APPLICATION_AUD")

# Construct URLs from CF Access team domain
TEAM_DOMAIN = "https://<team>.cloudflareaccess.com"
CERTS_URL = f"{TEAM_DOMAIN}/cdn-cgi/access/certs"
IDENTITY_URL = f"{TEAM_DOMAIN}/cdn-cgi/access/get-identity"


# A function to get the public keys using the `certs` endpoint
# And turn them into cryptographic entities for the library to consume
# Note the jwk.construct method.
def _get_public_keys() -> list:
    r = requests.get(CERTS_URL)
    public_keys = []
    jwk_set = r.json()

    for key_dict in jwk_set["keys"]:
        # Parse the JWK into a public key object usable by python-jose
        public_key = jwk.construct(key_dict)
        public_keys.append(public_key)

    return public_keys


# Verify token takes the cookie string. E.g. NHVaYe26MbtOYhSKkoKYdFVomg4i8ZJd8_-RU8VNbftc4TSMb4bXP3l3YlNWACwyXPGffz5aXHc6lty1Y2t4SWRqGteragsVdZufDn5BlnJl9pdR_kdVFUsra2rWKEofkZeIC4yWytE58sMIihvo9H1ScmmVwBcQP6XETqYd0aSHp1gOa9RdUPDvoXQ5oqygTqVtxaDr6wUFKrKItgBMzWIdNZ6y7O9E0DhEPTbE9rfBo6KTFsHAZnMg4k68CDp2woYIaXbmYTWcvbzIuHO7_37GT79XdIwkm95QJ7hYC9RiwrV7mesbY4PAahERJawntho0my942XheVLmGwLMBkQ
async def verify_token(CF_Authorization: str) -> dict:
    if not CF_Authorization:
        raise HTTPException(
            status_code=403, detail="Missing required CF authorization token"
        )

 # Getting the public keys
    keys = _get_public_keys()

    valid_token = False
    decoded_token = None

 # Here we are looping through each key and matching it to the jwt token. An alternative approach is matching the key id on the decoded 'header' part of the JWT.
    for key in keys:
        try:
            decoded_token = jwt.decode(
                CF_Authorization,
                key,
                # As a general rule of thumb you should always check the issuer is the same as the one you are expecting:
                issuer=TEAM_DOMAIN,
                # The same goes for audience:
                audience=AUD,
                # However, you do not have Audience tag(s) you can instead use the option below
                # options={"verify_aud": False},
                # Cloudflare uses the RSA256 algorithm to encrypt the signing keys
                algorithms=["RS256"],
            )
            valid_token = True
            break
        except JWTError:
            pass

    if not valid_token or not decoded_token:
        raise HTTPException(status_code=403, detail="Invalid token")

 # If jwt.decode manages to decode at least 1 key, then we can be certain that the JWT is verified.
 # In which case we can safely go ahead and get the user_identity as well.
 # You can also get this data earlier in the verification process.
 # However if you use audience tags to verify it is important that you do not return this data unless the token is verified.
    user_identity = requests.get(
        IDENTITY_URL,
        headers={"Cookie": "CF_Authorization=" + CF_Authorization},
    ).json()

    return user_identity

That's a short look at JWTs and Cloudflare Access. If you are curious about audience tags, issuer, or just best practices when handling JWTs you can read more about it at curity.io.

That's it! Be safe and use JWTs with care:)

— Filip