Decoding JSON Web Tokens (VCL)
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.
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, and is a great way to learn about the full capabilities of VCL. However, don't be intimidated! There are several steps you can skip here if they don't apply to your use case.
NOTE: This tutorial uses VCL. There is also a version available for the Compute platform.
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.
IMPORTANT: The VCL code in this tutorial does not support secret keys containing NUL characters.
You can use the following command to generate a random secret key without NUL characters:
$ env LC_ALL=C tr -d '\000' < /dev/random | head -c 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
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 Node.js, 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 you want to use (this is required by the JWT spec), e.g.:
{ "alg": "HS256", "typ": "JWT"}
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:
key
: ID (of your choice) of the key used to sign this token. (string, REQUIRED)exp
: Expiration Unix timestamp of this token. (number, optional)nbf
: Unix timestamp before which this token is not valid. (number, optional)tag
: Makes the token only valid for content tagged with the specified surrogate key. (string, optional)path
: URL path pattern in which the token is valid. (string, optional, may start with, end with, or contain one*
wildcard)ip
: IP of the client for which this token is valid. (string, optional)
For example (try out this example on jwt.io):
{ "key": "key1", "exp": 1592244331, "nbf": 1562254279, "tag": "subscriber-content", "path": "/foo"}
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 in the dictionary, where the dictionary item key is the name of the secret, and the value is the secret key itself (either the HMAC secret or the RSA public key), base-64 encoded. If you are experimenting in Fastly Fiddle, create the table by writing the code literally in the INIT space:
table solution_jwt_keys { "key1": "base64-encoded-signing-key-here"}
NOTE: If your secret signing key contains NUL characters, decoding it later with
digest.base64_decode
will produce unexpected results.
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'.
Declare variables
Now start to implement the decoding and validation in VCL which will run on Fastly's edge cloud.
During the validation and transformation of the JWT cookie into the payload data, you will need to store the data in various intermediate states. VCL is statically typed so you need to declare those variables ahead of time. Local variables are scoped to the subroutine, so it's best to declare them at the top of the scope.
declare local var.jwtSource STRING;declare local var.jwtHeader STRING;declare local var.jwtHeaderDecoded STRING;declare local var.jwtPayload STRING;declare local var.jwtPayloadDecoded STRING;declare local var.jwtSig STRING;declare local var.jwtStringToSign STRING;declare local var.jwtCorrectSig STRING;declare local var.jwtSigVerified BOOL;declare local var.jwtKeyID STRING;declare local var.jwtAlgo STRING;declare local var.jwtKeyData STRING;declare local var.jwtNotBefore INTEGER;declare local var.jwtExpires INTEGER;declare local var.jwtTag STRING;declare local var.jwtPath STRING;
JWTs comprise three sections: the header, payload, and signature. These declarations create a variable for each of these, plus one for the correct signature that can be separately calculated, so you can compare the signature supplied with what is expected, and a bunch of other intermediate state variables.
Now, add some variables for option settings:
declare local var.jwtOptionTimeInvalidBehavior STRING;set var.jwtOptionTimeInvalidBehavior = "anon"; # Choose from 'anon' or 'block'declare local var.jwtOptionPathInvalidBehavior STRING;set var.jwtOptionPathInvalidBehavior = "anon"; # Choose from 'anon' or 'block'declare local var.jwtTokenSource STRING;set var.jwtTokenSource = "cookie"; # Choose from 'cookie' or 'query'declare local var.jwtOptionAnonAccess STRING;set var.jwtOptionAnonAccess = "allow"; # Choose from 'allow' or 'deny'
Detect, extract and decode the JWT
If the request contains an authentication cookie (called auth
in this example) which passes a basic syntax check for the format of a JWT (header.payload.signature
), it can be read and validated. You could also obtain the JWT from a query string parameter.
The three parts of the token are separated by dots, and will be extracted into the special re.group
object by the regular expression engine as part of the if
statement. These parts can now be assigned to more helpfully-named variables:
if (var.jwtTokenSource == "cookie") { set var.jwtSource = subfield(req.http.Cookie, "auth", ",");} else { set var.jwtSource = subfield(req.url.qs, "auth", "&");}
if (var.jwtSource ~ "^([A-Za-z0-9-_=]+)\.([A-Za-z0-9-_=]+)\.([A-Za-z0-9-_.+/=]*)\z") { set var.jwtHeader = re.group.1; set var.jwtHeaderDecoded = digest.base64url_decode(var.jwtHeader); set var.jwtPayload = re.group.2; set var.jwtPayloadDecoded = digest.base64url_decode(var.jwtPayload); set var.jwtSig = re.group.3;}
All three components of the token are base64 encoded, and we need to keep the encoded versions hanging around, because the payload and header are needed in encoded form in order to calculate the correct signature.
Extract required signing data from the JWT
In order to construct a signature, you need to know which algorithm and key to use:
set var.jwtAlgo = if(var.jwtHeaderDecoded ~ {"\{(?:.+,)?\s*"alg" *: *"([^"]*)""}, re.group.1, "");set var.jwtKeyID = if(var.jwtPayloadDecoded ~ {"\{(?:.+,)?\s*"key" *: *"([^"]*)""}, re.group.1, "");set var.jwtKeyData = digest.base64_decode(table.lookup(solution_jwt_keys, var.jwtKeyID, ""));set var.jwtStringToSign = var.jwtHeader "." var.jwtPayload;set var.jwtNotBefore = std.atoi(if(var.jwtPayloadDecoded ~ {"\{(?:.+,)?\s*"nbf" *: *"?(\d+)[",\}]"}, re.group.1, "0"));set var.jwtExpires = std.atoi(if(var.jwtPayloadDecoded ~ {"\{(?:.+,)?\s*"exp" *: *"?(\d+)[",\}]"}, re.group.1, "0"));set var.jwtPath = if(var.jwtPayloadDecoded ~ {"\{(?:.+,)?\s*"path" *: *"([^\"]+)""}, re.group.1, "");set var.jwtTag = if(var.jwtPayloadDecoded ~ {"\{(?:.+,)?\s*"tag" *: *"([^\"]+)""}, re.group.1, "");
IMPORTANT: Currently, Fastly does not support native JSON decoding, which is why we need to use regex to extract data from the JWT source.
Verify the signature
To calculate the expected signature, we use the concatenated header and payload (in base64-encoded form), and calculate a signature using the appropriate algorithm, and the secret key from your keys configuration table. You might choose to produce all your JWTs using the same algorithm but we'll implement support for all of them.
if (var.jwtHeader) { if (var.jwtAlgo !~ "^HS256|HS512|RS256|RS512$") { error 618 "jwt:algo-not-supported"; } else if (var.jwtKeyData == "") { error 618 "jwt:key-not-found"; } else if (std.prefixof(var.jwtAlgo, "HS")) {
if (var.jwtAlgo == "HS256") { set var.jwtCorrectSig = digest.hmac_sha256_base64(var.jwtKeyData, var.jwtStringToSign); } else { set var.jwtCorrectSig = digest.hmac_sha512_base64(var.jwtKeyData, var.jwtStringToSign); }
# Convert from standard base64 with padding to URL-safe base64 with no padding, consistent with the JWT specification. set var.jwtCorrectSig = std.replace_suffix(std.replaceall(std.replaceall(var.jwtCorrectSig, "+", "-"), "/", "_"), "=", "");
if (digest.secure_is_equal(var.jwtCorrectSig, var.jwtSig)) { set var.jwtSigVerified = true; } else { set var.jwtSigVerified = false; }
} else if (var.jwtAlgo == "RS256") { set var.jwtSigVerified = digest.rsa_verify(sha256, var.jwtKeyData, var.jwtStringToSign, var.jwtSig, url); } else if (var.jwtAlgo == "RS512") { set var.jwtSigVerified = digest.rsa_verify(sha512, var.jwtKeyData, var.jwtStringToSign, var.jwtSig, url); }
if (!var.jwtSigVerified) { error 618 "jwt:signature-fail"; }}
In error conditions, trigger a unique HTTP error code of your choice, so that the error can be handled by the vcl_error
subroutine. Passing a known value in the error status text with more detail helps to add context to the error and avoid clashes with other solutions that might also be using the same error code.
Check for time constraints (optional)
In your JWT, you can specify a 'not before' and 'not after' time, via the nbf
and exp
properties of the payload.
A token that is missing the exp
property should be considered invalid. If they are specified, check that the current time is within the allowed constraints:
if (var.jwtExpires == 0) { if (var.jwtOptionTimeInvalidBehavior == "anon") { set var.jwtSigVerified = false; } else { error 618 "jwt:expires-not-present-or-valid"; }} else if ((var.jwtNotBefore > 0 && !time.is_after(now, std.integer2time(var.jwtNotBefore))) || (var.jwtExpires > 0 && time.is_after(now, std.integer2time(var.jwtExpires)))) { if (var.jwtOptionTimeInvalidBehavior == "anon") { set var.jwtSigVerified = false; } else { error 618 "jwt:time-out-of-bounds"; }}
Rather than throwing an error here, it often makes more sense to allow users with time-invalid tokens to be regarded as anonymous, but your use case may vary so we're supporting both options here.
Check for path constraint (optional)
Another thing we're supporting in the JWT is the path
property, which allows a token to be scoped to a URL path. If this is set within the token, verify it now:
if (var.jwtPath ~ {"^(\*)?(\/[^\*]+)(/\*)?\z"}) { if (
# Exact match, eg "/index.html" (!re.group.1 && !re.group.3 && var.jwtPath != req.url.path) ||
# Prefix match eg "/products/..." (!re.group.1 && re.group.3 && !std.prefixof(req.url.path, re.group.2)) ||
# Suffix match eg ".../protected.html" (re.group.1 && !re.group.3 && !std.suffixof(req.url.path, re.group.2)) ||
# Both eg ".../somedirectory/..." (re.group.1 && re.group.3 && !std.strstr(req.url.path, re.group.2)) ) { if (var.jwtOptionPathInvalidBehavior == "anon") { set var.jwtSigVerified = false; } else { error 618 "jwt:path-mismatch"; } } log "Checked path constraint";}
There are many possible matching schemes you could employ here. For this solution, we'll assume that the path constraint comprises whole URL path segments, so, for example we'll accept a constraint of admin/*
but not /ad*
.
Check for tag constraint (optional)
Fastly has a mechanism for tagging content known as surrogate keys. These are very useful as a way to group together resources that share a common trait, and we also offer the ability to purge all objects that share a particular tag.
If you wish, you can support constraining tokens to only be valid for objects tagged with a particular key. We already extracted the tag from the token into var.jwtTag
. Since you don't yet know whether the content being requested has the necessary tag, start by copying the constraint into a request header:
if (var.jwtTag != "") { set req.http.auth-require-tag = var.jwtTag;}
Now, in cases where a token is otherwise valid, the resource will be fetched, whether from cache or origin, and can be examined in the vcl_deliver
subroutine to see if it matches your tag constraint:
declare local var.jwtRequiredTag STRING;declare local var.jwtAvailableTags STRING;
if (req.http.auth-require-tag) { set var.jwtRequiredTag = " " req.http.auth-require-tag " "; set var.jwtAvailableTags = " " req.http.Surrogate-Key " "; log "Checked tag constraint: " var.jwtRequiredTag " vs. " var.jwtAvailableTags;
if (!std.strstr(var.jwtAvailableTags, var.jwtRequiredTag)) { unset req.http.auth-require-tag; set req.http.Fastly-JWT-Error = "jwt:tag-missing"; restart; }}
Fastly automatically strips the Surrogate-Key
header from the object in deliver, and moves it to a request header, so that you don't leak information about how your objects are tagged. That's why you need to check req.http.Surrogate-Key
here, and not resp.http.Surrogate-Key
. Finally, performing an error
is not permitted in vcl_deliver
, so instead, trigger a restart
and catch the error at the top of vcl_recv
:
# Place this just below all the `declare` statements
if (req.http.Fastly-JWT-Error) { error 618 req.http.Fastly-JWT-Error;}
Decode and extract the token payload
With all checks completed, you can now extract your profile data from the token.
The fields within the payload are not specified by the JWT format, so these are up to you. We've assumed in this example that the payload contains an object with properties uid
, groups
, name
, and admin
, but yours will likely be different.
The important thing is that you extract the fields to separate HTTP headers, so that this data can be used for cache variation.
if (var.jwtSigVerified) { set req.http.Auth-State = "authenticated"; set req.http.Auth-UserID = if(var.jwtPayloadDecoded ~ {"\{(?:.+,)?\s*"uid" *: *"([^\"]+)""}, re.group.1, ""); set req.http.Auth-Groups = if(var.jwtPayloadDecoded ~ {"\{(?:.+,)?\s*"groups" *: *"([^\"]+)""}, re.group.1, ""); set req.http.Auth-Name = if(var.jwtPayloadDecoded ~ {"\{(?:.+,)?\s*"name" *: *"([^\"]+)""}, re.group.1, ""); set req.http.Auth-Is-Admin = if (var.jwtPayloadDecoded ~ {"\{(?:.+,)?\s*"admin" *: *true"}, "1", "0");} else { if (var.jwtOptionAnonAccess == "allow") { set req.http.Auth-State = "anonymous"; unset req.http.Auth-UserID; unset req.http.Auth-Groups; unset req.http.Auth-Name; unset req.http.Auth-Is-Admin; } else { error 618 "jwt:anonymous"; }}
For some use cases, it makes sense to only allow requests to origin if a token is present. It's important to unset these headers if the user is judged to be anonymous, because otherwise the user could send these headers themselves, and we would simply forward them to your origin server.
Handle the error cases
When that custom error is triggered, execution flow moves to the vcl_error
subroutine. You can use this to construct a custom response, which is likely to be a redirect to a login page.
if (obj.status == 618 && obj.response ~ "^jwt:(.+)\z") { set obj.status = 307; set obj.response = "Temporary redirect"; set obj.http.Location = "/login?return_to=" + urlencode(req.url); set obj.http.Fastly-JWT-Error = re.group.1; synthetic ""; return (deliver);}
This pattern, known as a 'synthetic response', involves triggering an error from somewhere else in your VCL, catching it in the vcl_error
subroutine, and then converting the error obj
into the response that you want to send to the client. To make sure you trap the right error, it's a good idea to use a non-standard HTTP status code in the 6xx
range, and also to set an error 'response text' as part of the error
statement. These two pieces of data then become obj.status
and obj.response
in the vcl_error
subroutine. Checking both of them will ensure you are trapping the right error.
Once you know you are processing the error condition that you triggered from your earlier code, you can modify the obj
to create the response you want. Normally this includes some or all of the following:
- Set
obj.status
to the appropriate HTTP status code - Set
obj.response
to the canonical HTTP response status descriptor that goes with the status code, e.g., "OK" for 200 (this feature is no longer present in HTTP/2, and has no effect in H2 connections) - Add headers using
obj.http
, such asobj.http.content-type
- Create content using
synthetic
. Long strings, e.g. entire HTML pages, can be included here using the{"...."}
syntax, which may include newlines - Exit from the
vcl_error
subroutine by explicitly performing areturn(deliver)
Remove the cookie
Now that 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.
This code goes at the end of the vcl_recv
subroutine, because we want it to run regardless of whether the cookie was valid or not.
if (var.jwtTokenSource == "cookie") { unset req.http.Cookie:auth;}
HINT: If a cookie is not present, trying to unset it is a no-op, and not harmful.
Use the authentication data on your origin server
The user profile data is presented to your origin server as HTTP headers, prefixed auth-
. In the examples above, we created Auth-State
, Auth-UserID
, Auth-Groups
, Auth-Name
, and Auth-Is-Admin
. You can use this information to adjust the response you generate.
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-userid
s 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
Next steps
This solution contains a VCL table that stores credentials. You can define the table using an private edge dictionary, which enables you to manage the table 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.
Related content
Reference
Quick install
The embedded fiddle below shows the complete solution. Feel free to run it, and click the INSTALL
tab to customize and upload it to a Fastly service in your account:
Once you have the code in your service, you can further customize it if you need to.
All code on this page is provided under both the BSD and MIT open source licenses.