Decoding JWT (Compute@Edge)

IMPORTANT: The content on this page is written for version 0.8.0 of the fastly crate. If you have previously used this example, your project may be using an older SDK version. View the changelog to learn how to migrate your program.

The popular JSON Web Token format is a useful way to maintain authentication state and synchronize it between client and server. You are using JWTs as part of your authentication process and you want to decode and validate the tokens at the edge, so that content can be cached efficiently for all authentication states.

Illustration of concept

Instructions

The solution explained on this page is a particularly comprehensive one, covering multiple use cases and potential constraints that you might want to place on your token. However, don't be intimidated! There are several steps you can skip here if they don't apply to your use case.

HINT: This tutorial is also available for the VCL implementation.

Generate a secret signing key

Most authentication tokens protect against manipulation using a signature, and JSON Web Tokens are no exception. Therefore, start by generating a secret signing key, which can be used to generate a signature for your token (and therefore validate that the token the user submits is valid). You may already have this if you are already generating your JWTs at your origin server.

Using an HMAC key (simpler and shorter)

An HMAC key is simply any string of your choice. Using openssl is a great way to generate a random string:

$ openssl rand 32 -base64

Using an RSA key (more secure)

To use an RSA key, generate a key pair, and extract the public key. Make sure you keep a record of the passphrase on the key if you choose to set one:

$ ssh-keygen -t rsa -b 4096 -m PEM -f jwtRS256.key
$ openssl rsa -in jwtRS256.key -pubout -outform PEM -out jwtRS256.key.pub

This tutorial will use an RSA public key to verify the JWT signature. If you prefer, you can use the sample key provided for testing.

Set a valid JWT at your origin

In order for your users to present a request to Fastly that contains a JWT, they need to have previously received that token from you. Most likely, you're going to want to set this in a cookie on a previous response, but you could also bake the JWT into a link via a query parameter, or even into the URL path itself. Regardless, you're going to need to generate a JWT. Most programming technologies have a package for generating JWTs, such as those for NodeJS, Ruby, and PHP. You can also test JWTs using the JWT.io tool.

Within the header section of the token, make sure you include the name of the signing algorithm (alg) you want to use and the ID (of your choice) of the key used to sign this token (kid), e.g.:

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

Within the payload section of the token, this is where you put your own session data, but to enable Fastly to verify your token, some payload fields have a special meaning to us in this tutorial:

  1. exp: Time after which the JWT expires. (Unix timestamp, optional)
  2. nbf: Time before which the JWT must not be accepted for processing. (Unix timestamp, optional)
  3. iss: The party that "created" the token and signed it with its private key. (string, optional)
  4. aud: Recipient(s) for which the JWT is intended. (array of strings, optional)
  5. path: URL path pattern in which the token is valid. (string, optional, may start with, end with, or contain one * wildcard)

For example (try out this example on jwt.io):

{
"exp": 1721636315,
"nbf": 1658477915,
"aud": ["https://httpbin.org"],
"iss": "http://example.com",
"path": "/headers",
"uid": "1234567890",
"name": "John Doe",
"groups": "subscribers uk eu tier-gold",
"admin": true
}

If you prefer, you can use the sample JWT provided for testing.

Make your secret signing key accessible to Fastly

If you are installing this solution in a Fastly service, set up a private edge dictionary called solution_jwt_keys to store your secret. The secret is a single key-value pair, item key is the name of the secret, and the value is the secret key itself (the RSA public key), base-64 encoded. If you are experimenting on your local machine, populate the dictionary data in your fastly.toml file:

fastly.toml
TOML
[local_server]
[local_server.dictionaries]
[local_server.dictionaries.solution_jwt_keys]
format = "inline-toml"
[local_server.dictionaries.solution_jwt_keys.contents]
"key1" = "base64-encoded-rsa-public-key-here"

When you come to want to rotate your keys, you will need to have the old key and new keys briefly valid at the same time, so the approach here allows for multiple keys to be defined. The name (in this example, key1) is used to differentiate between them, and could be a version number or a date, e.g., key-june2019.

Validating JSON Web Tokens

Now you (or your end users) have a valid JWT, you can start to implement the edge code to validate the JWT at the edge. This tutorial implements a service on our Compute@Edge platform (using Rust), but if you prefer to work in VCL, we have an equivalent tutorial for decoding JWTs in VCL.

First, add the jwt-simple, serde, and base64 crates to your Cargo.toml file:

Cargo.toml
TOML
[dependencies]
fastly = "0.8.6"
serde = "1.0.140"
base64 = "0.13.0"
jwt-simple = "0.11.0"

Rust's module system can be used to separate the bulk of your JWT validation code from your application logic. Create a file called src/jwt.rs, and define a public function called validate_token_rs256:

jwt.rs
Rust
use fastly::Error;
use jwt_simple::prelude::*;
use serde::{de::DeserializeOwned, Serialize};
// Validates a JWT signed with RS256, and verifies its claims.
pub fn validate_token_rs256<CustomClaims: Serialize + DeserializeOwned>(
token_string: &str,
) -> Result<JWTClaims<CustomClaims>, Error> {
// 1. Retrieve the key identifier (kid) from the token.
// 2. Retrieve the key matching the key identifier from the config store.
// 3. Verify the token signature using the key.
// 4. Verify any additional claims in the token payload.
}

This function will be responsible for taking a serialised JWT as a string, and returning a Result that either contains a set of parsed JWTClaims, or an error.

The first step is to retrieve the key identifier (kid) from the JWT header. jwt-simple provides a Token::decode_metadata function that can be used to decode token information that can be useful prior to verification:

jwt.rs
Rust
// Peek at the token metadata before verification and retrieve the key identifier,
// in order to pick the right key out of the config store.
let metadata = Token::decode_metadata(token_string)?;
let key_id = match metadata.key_id() {
None => {
return Err(Error::msg(
"Failed to decode public key identifier from JWT header.",
))
}
Some(value) => value,
};

Now attempt to match the retrieved key identifier to an entry in solution_jwt_keys:

jwt.rs
Rust
// Retrieve the public key matching the key ID from the config store.
let config_store = fastly::ConfigStore::open("solution_jwt_keys");
let key_base64 = match config_store.get(key_id) {
None => {
return Err(Error::msg(format!(
"Failed to retrieve public key with identifier {}.",
key_id
)))
}
Some(value) => value,
};

The key is stored in the dictionary in base64 encoded form, so it must be decoded first, and the resulting bytes interpreted as an UTF8 string. The key's source will then look something like:

-----BEGIN RSA PUBLIC KEY-----
...RSA public key data...
-----END RSA PUBLIC KEY-----

Parse this into a RS256PublicKey struct, which will be used to verify the token's signature:

jwt.rs
Rust
// Decode the RS256 public key.
let decoded_key_bytes = base64::decode(key_base64).unwrap();
let decoded_key = std::str::from_utf8(&decoded_key_bytes).unwrap().to_string();
let public_key = RS256PublicKey::from_pem(&decoded_key)?;

Finally, verify the token's signature using the RS256 public key:

jwt.rs
Rust
let mut verification_options = VerificationOptions::default();
public_key.verify_token::<CustomClaims>(token_string, Some(verification_options))

The return value from verify_token is a Result of CustomClaims type, which can then be used to read the contents of the verified JWT.

HINT: The VerificationOptions struct provides additional features for the verification of standard claims in the token payload (e.g., aud and iss). Key expiration, start time, authentication tags, etc., are automatically verified.

jwt.rs
Rust
// Set any additional verification options for standard claims in the JWT payload (optional).
verification_options.allowed_issuers = Some(HashSet::from_strings(&["http://example.com"]));
verification_options.allowed_audiences = Some(HashSet::from_strings(&["https://httpbin.org"]));

Detect and validate the JWT

This example assumes that the request contains an authentication cookie called auth, and you want to obtain the JWT by reading the cookie. For completeness, it will fall back to obtaining the JWT from a query string parameter.

First, you will need to parse the Cookie header in order to retrieve the authentication cookie. Split this parsing logic from your application code, by creating a src/cookie.rs file:

cookie.rs
Rust
use std::collections::HashMap;
// Parse a Cookie header.
pub fn parse(cookie_string: &str) -> HashMap<&str, &str> {
cookie_string
.split("; ")
.filter_map(|kv| {
kv.find('=').map(|index| {
let (key, value) = kv.split_at(index);
let key = key.trim();
let value = value[1..].trim();
(key, value)
})
})
.collect()
}

In src/main.rs, try to retrieve the JWT from the auth cookie first, and fall back on the auth querystring parameter:

main.rs
Rust
mod cookie;
use fastly::http::{header, StatusCode};
use fastly::{Body, Error, Request, Response};
#[fastly::main]
fn main(mut req: Request) -> Result<Response, Error> {
// Parse the Cookie request header.
let cookie_header = req.remove_header_str(header::COOKIE).unwrap_or_default();
let cookie = cookie::parse(&cookie_header);
// Detect the JWT.
// First, try to retrieve the retrieve the JWT from the "auth" cookie.
let jwt_source = match cookie.get("auth") {
Some(value) => value,
// Try to retrieve the JWT from the "auth" query parameter.
None => match req.get_query_parameter("auth") {
Some(value) => value,
// Return an unauthorized response if no JWT is found.
None => return Ok(unauthorized_response("No JWT supplied.")),
},
};
}

There are a few instances when you might want to return a response early, without forwarding the request to your origin – for example, if no JWT is found in the request, or when the JWT is invalid. To avoid repetition, write a function that returns a synthetic 401 Unauthorized response:

main.rs
Rust
fn unauthorized_response(body: impl Into<Body>) -> Response {
Response::from_body(body)
.with_status(StatusCode::UNAUTHORIZED)
}

Now you could validate the JWT you obtained using the jwt::validate_token_rs256 function you wrote earlier:

// Validate the JSON Web Token.
if jwt::validate_token_rs256::<jwt_simple::claims::NoCustomClaims>(jwt_source).is_err() {
return Ok(unauthorized_response("Invalid JWT supplied."));
}

Validating custom claims (optional)

Using the code above, you will have validated only the standard claims in the JWT payload. In practice, you may want to also validate custom claims in the JWT, such as session data.

To extract this data, define a (de)serializable struct that represents your custom claims, and pass its type to jwt::validate_token_rs256:

main.rs
Rust
mod jwt;
use serde::{Deserialize, Serialize};
// Declare any custom claims that will be included in the JWT payload.
// These claims can be used to perform any additional validation on the JWT.
#[derive(Serialize, Deserialize)]
struct CustomClaims {
admin: bool,
path: String,
groups: String,
uid: String,
name: String,
}
// Validate the JWT and retrieve custom claims.
let custom_claims = match jwt::validate_token_rs256::<CustomClaims>(jwt_source) {
Ok(claims) => claims.custom,
Err(error) => return Ok(unauthorized_response(format!("{}", error))),
};

Now you can perform validation along further constraints, like the path claim which allows a token to be scoped to a URL path, or the groups claim which allows a token to be scoped to specific user groups:

main.rs
Rust
// Check for path constraint (optional).
if req.get_path() != custom_claims.path {
return Ok(unauthorized_response("Path constraint failed."));
}
// Check for groups constraint (optional).
if !custom_claims.groups.contains("subscribers") {
return Ok(unauthorized_response(format!(
"{} is not a subscriber.",
custom_claims.name
)));
}

Use the authentication data on your origin server

You could validate the claims in the token on the edge. In practice, you might want to send the contents of the verified token to your origin server along with the request. Authorization decisions will sometimes need more information than you have available at the edge. The content that the origin server returns may also vary depending on the user's profile. Consider the case above where the JWT is being validated for a group membership and requires the user to be a member of the subscribers group. You might prefer to send requests from logged-in-but-non-paying users to the origin anyway, so you can serve them a paywall.

To maximise cache efficiency it makes sense to send each piece of data as a separate HTTP header. For example, you could prefix each item with Auth- as shown below. You can use this information in your backend server to adjust the response you generate.

main.rs
Rust
req.set_header("Auth-State", "authenticated");
req.set_header("Auth-UserID", custom_claims.uid);
req.set_header("Auth-Name", custom_claims.name);
req.set_header("Auth-Groups", custom_claims.groups);
req.set_header("Auth-Is-Admin", format!("{}", custom_claims.admin));

It's vital that you tell Fastly which data you used to determine the content of the page, so we can cache multiple variants of the response where appropriate. Sometimes, the user might request some resource that is not affected by their authentication state - perhaps an image - and in this case you don't need to do anything. We will cache just one copy of the resource, and use it to satisfy all requests. However, if you do use any Auth- headers to decide what to output, then you need to tell us that you did this, using a Vary header:

Vary: Auth-State

In this case, you're saying that the response contains information that varies based on the Auth-State header, so Fastly needs to keep multiple copies of this resource, one for each of the possible values of auth-state (only two in our example here: "Authenticated" and "Anonymous"). We don't need to keep separate copies for all the different Auth-UserIDs though, because you didn't use that information to generate the page output.

Authentication data with low granularity, such as 'is authenticated', 'level', 'role', or 'is admin' are really good properties to use to vary page output in a way that still allows it to be efficiently cached. Medium granularity data such as 'Groups' (which we assume to be a string containing multiple group names) can also work, but think about normalising this kind of data, e.g., by making it lowercase and sorting the tokens into alphabetical order. Making use of high granularity data such as 'name' and 'user ID' generally renders a response effectively uncacheable at the edge.

It's possible that you always inspect something like Auth-State, and then for certain states, you also inspect another property like UserID. That's fine, and in that case, the responses that have inspected userID should include it in the vary header:

Vary: Auth-State, Auth-UserID

Finishing touches

Once the authentication state data from the cookie has been resolved, you no longer need to keep the cookie around. In fact, it's better that you don't, because if you do, you will send two sources of authentication information to the origin server, and you can't control which ones the server will use. Keep your application better encapsulated by removing data higher up the stack if it should not penetrate any lower.

Finally:

  1. Remember to remove the cookie before forwarding the request to the origin server;
  2. Set appropriate Auth-State and Vary headers, and expire the auth cookie on any synthetic responses.
main.rs
Rust
req.remove_header(COOKIE);
fn unauthorized_response(body: impl Into<Body>) -> Response {
Response::from_body(body)
.with_status(StatusCode::UNAUTHORIZED)
.with_header("Auth-State", "anonymous")
.with_header(header::VARY, "Auth-State")
.with_header(
header::SET_COOKIE,
"auth=expired; Expires=Thu, 01 Jan 1970 00:00:00 UTC;",
)
}

Next steps

This solution stores JWT (public) signing keys in a private edge dictionary, which enables you to manage credentials via HTTP API calls, without having to clone and activate new versions of your service, and without having the credential data visible in your service configuration.

If your origin servers are exposed to the internet (and not privately peered with Fastly), then you may want to take steps to ensure that users cannot send 'authenticated' requests directly to origin. You can do this with a client certificate, a pre-shared key, or by adding Fastly network IP addresses to a firewall.

Testing

To quickly test your solution on your local machine, use the base64-encoded RS256 public key:

LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQ0lqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FnOEFNSUlDQ2dLQ0FnRUE1bGtPQUVZOE1CNnhoSmQxZVVCKwpvaGw0OVVWN2luWEFIRXZZZDcyUmJhcFgxNmVKK1IzT1l0aVAwYVVrYzhPdExZc2ZYTVlsMVNYU2c4bFNBRktpCkJVdmQxRFpJMVZObGN4QUVGdStESWFPNkEvQ2NiSWVybTIwek41UUNkZ2UzY1NuT1FWOHpOQmd4Sk5iM2IvVnoKREtwUkRLUGwxMldVb2l3SnVlL3Npc2hoaW1zaHhtOEk4NUFsSkYwamVIbXpMaHBLTUU2eVUwVzExTCt4dUFZUgpEKzJQV21uMWt4ODZhTU5QUE10OVZMS1NIK0MrQnNuTFhqK1BSMTRxdHV0Y3NWeTNRSHFzMVdoaDNPMTZHMWdHCi9ycWMvQjdrVW9GdWZFbHR5VkE3cGtQMzAwRG5TVDdLbmVVOTZ2RDh5NDMwYnZsSG1HSkMzRzF2T3crU3lpUzAKMENhVE9IWjdpWUIrb1lKZU5Qa0JzQUhobk16VGlaNEtuc2hDMnFLTnBNVlZuWWRUYW4yVER1SXd3elVsLys0ZgoxMHhyREhkZHdiQzFFa096N2d1dGRGNXZzMXdWcEdBRG9yL0VqVkdOSXhOem1vNzA1bU82WUR5OEJlOThnbTBHCkNSck5JVnFCYVpUWDEvbDRISHVyZzg2SCt5WjFxOWVRS3pLNGFFWmMzSFJhZXFZSTBnQ3FmSXRkQk5COHpjSGYKS0FOMDZhaFBpM0NmSDBZTlZFVm1ZUVlmdGZHR0x1d2M2T0VEOGsrT21qUVljclNiRG9pbmpmT3hweElKV0dNeQpyYW1TcmN1aWRUdGcycEd2RVcwVlBvMHltc01xZmJTQnFvajJNdCt0MmFZT2hPVnU3dm5qaGlHZlI0cTE3UGROCmdXc3dyMzZMUnBTdjV2OW1lVFUyeWw4Q0F3RUFBUT09Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo=

And set an auth cookie or querystring parameter on requests to your development service, to the following JWT:

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtleTEifQ.eyJleHAiOjE3MjE2MzYzMTUsIm5iZiI6MTY1ODQ3NzkxNSwiYXVkIjpbImh0dHBzOi8vaHR0cGJpbi5vcmciXSwiaXNzIjoiaHR0cDovL2V4YW1wbGUuY29tIiwicGF0aCI6Ii9oZWFkZXJzIiwidWlkIjoiMTIzNDU2Nzg5MCIsIm5hbWUiOiJKb2huIERvZSIsImdyb3VwcyI6InN1YnNjcmliZXJzIHVrIGV1IHRpZXItZ29sZCIsImFkbWluIjp0cnVlfQ.G27NBaAh-MbumHowTRAZWNFPnsd27mIvJQag4nxTzNTsjGzH5whvAtcLNNqD6kXid2yJfZdqU5EpdPyEJjL7h-FuwfLWmbprAScGKnlwjkPpBqbb_xeeYJmneXGDrd2yuYWNbIEKR4RE645GOLXR7Auj5NpCxFk4uuhTtdIqZxWJSKYUgTsH_XDvnT08Pf-Enep8NnYPQDBByZhkFiYZDN0_t1jjENf-0yqDLD85I1Ep3OrzRs-Bj8o9v6f3GzEEnp-WkmsB4eIyWbh9kL-YV2ApsbP7sjP5Mh_-UUyUjuAXspmT6et2C7oH4DfhF1WPohJl5YRlM5BsgrfgRodfBHg2bpe0YPMZ_ptFPq9ipDci1FUrddVchJsjUqyH1JQgcQETjR-ynLNmMYgKljSLEC_qqHnG3KVk_eAZRN5-aRIr7mLCqSZzD4GHb4F-XhwRQ0KgwzEyAoD32aZLHwR5G7TrmVQ3Aw8__ZeMhxr8-BPGVcdSomKsx8VW28Wm0YrPD-7Lf7bWnal0-lS7XnTxxRSuiCLIJBD3HT6AXzkN4DTp4Kn0TbIf9A9nOYQn2e9DSv2AGFz9d60qr4Q5-y7aidgEHbXdf-D7A6HClmr6-ibKA3bTB62m5w95DGSFTQNPLkUD6SJmBAga5__ERaHgR7RJeyZnAl5NzREXJt2rTBc

Further reading